mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a430e8546 |
@@ -18,6 +18,7 @@ import {
|
||||
type AssistantChatRequest,
|
||||
type AssistantDecisionsRequest,
|
||||
type AssistantErrorCode,
|
||||
type AssistantInsertFoundPlaceRequest,
|
||||
type AssistantRenderUploadResponse,
|
||||
type AssistantScope,
|
||||
type AssistantShareRequest,
|
||||
@@ -143,6 +144,13 @@ export const assistantApi = {
|
||||
body: AssistantAddFoundPlaceRequest
|
||||
): Promise<AssistantAddFoundPlaceResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
|
||||
// "+ Tour": a found place taken into the saved tour open in the planner, held as a change like "+ Day 3".
|
||||
insertFoundPlace: (
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
body: AssistantInsertFoundPlaceRequest
|
||||
): Promise<AssistantAddFoundPlaceResponse> =>
|
||||
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places/tour`, body).then((r) => r.data),
|
||||
// A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it.
|
||||
takeProposal: (
|
||||
tripId: number,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AssistantFoundPlace } from '@trek/shared';
|
||||
import { Check, MapPin, Plus, Star } from 'lucide-react';
|
||||
import { Check, MapPin, Plus, Route, Star } from 'lucide-react';
|
||||
import { createElement, type ReactNode, useRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
@@ -25,6 +25,24 @@ function AddButton({ place, strip, tall }: { place: AssistantFoundPlace; strip:
|
||||
);
|
||||
}
|
||||
|
||||
/** "+ Tour": takes the place into the saved tour open in the planner as a stop, held as a change in the chat. */
|
||||
function TourButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!strip.tour) return null;
|
||||
return (
|
||||
<Tooltip label={t('assistant.place.addToTourHint', { name: strip.tour.name })} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => strip.addToTour(place)}
|
||||
className={`${ACTION} ${tall ? 'h-10' : 'h-7'} min-w-0 px-2`}
|
||||
>
|
||||
<Route size={12} aria-hidden="true" className="flex-shrink-0" />
|
||||
<span className="truncate">{t('assistant.place.addToTour')}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
type Category = ReturnType<Strip['categoryOf']>;
|
||||
|
||||
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
|
||||
@@ -185,8 +203,9 @@ export default function AssistantPlaceCard({
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
|
||||
<div className="mt-auto flex min-w-0 gap-1.5 px-3 pb-3 pt-2.5">
|
||||
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
|
||||
<TourButton place={place} strip={strip} tall={tall} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { fetchPhoto } from '../../services/photoService';
|
||||
import { assistantChatKey } from '../../store/assistantChatKey';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { setOpenTour } from '../../store/openTourStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { setForcedOffline } from '../../sync/networkMode';
|
||||
import AssistantPlaceStrip from './AssistantPlaceStrip';
|
||||
@@ -105,6 +106,7 @@ afterEach(() => {
|
||||
useAuthStore.setState(AUTH, true);
|
||||
globalThis.IntersectionObserver = RealObserver;
|
||||
setForcedOffline(false);
|
||||
setOpenTour(null);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -238,3 +240,22 @@ describe('AssistantPlaceStrip', () => {
|
||||
expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('"+ Tour" on a found place (P6)', () => {
|
||||
it('FE-AI-PLACES-011: shows only while a saved tour of this trip is open, and holds the place as its stop', () => {
|
||||
seed();
|
||||
const insertFoundPlace = vi.fn(async () => 'held' as const);
|
||||
useAssistantStore.setState({ insertFoundPlace });
|
||||
setOpenTour({ tripId: TRIP, placeId: 64, name: 'Herzogstand' });
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to tour' }));
|
||||
expect(insertFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 64);
|
||||
});
|
||||
|
||||
it('FE-AI-PLACES-012: a tour open on another trip, or a traveller who may not edit, gets no "+ Tour"', () => {
|
||||
seed();
|
||||
setOpenTour({ tripId: TRIP + 1, placeId: 64, name: 'Elsewhere' });
|
||||
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
|
||||
expect(screen.queryByRole('button', { name: 'Add to tour' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
|
||||
decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
|
||||
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
|
||||
no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null },
|
||||
no_tracks_found: { messageKey: 'assistant.error.noTracksFound', action: null },
|
||||
import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null },
|
||||
generic: {
|
||||
messageKey: 'assistant.error.generic',
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
|
||||
import { ChartNoAxesColumn, Circle, CircleCheck, CircleSlash, Footprints, Luggage, Route } from 'lucide-react';
|
||||
import {
|
||||
ChartNoAxesColumn,
|
||||
Circle,
|
||||
CircleCheck,
|
||||
CircleSlash,
|
||||
Footprints,
|
||||
Luggage,
|
||||
MapPinMinus,
|
||||
MapPinPlus,
|
||||
Route,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
|
||||
import type { EntityTarget, PackLook } from '../cardTypes';
|
||||
@@ -95,6 +105,8 @@ export const TOUR_LOOK = {
|
||||
countTour: { Icon: ChartNoAxesColumn, tone: 'success', labelKey: 'assistant.change.verb.countTour' },
|
||||
linkPacking: { Icon: Luggage, tone: 'success', labelKey: 'assistant.change.verb.linkPacking' },
|
||||
unlinkPacking: { Icon: Luggage, tone: 'info', labelKey: 'assistant.change.verb.unlinkPacking' },
|
||||
addTourStop: { Icon: MapPinPlus, tone: 'success', labelKey: 'assistant.change.verb.addTourStop' },
|
||||
removeTourStop: { Icon: MapPinMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeTourStop' },
|
||||
},
|
||||
tools: {},
|
||||
steps: {
|
||||
@@ -106,6 +118,8 @@ export const TOUR_LOOK = {
|
||||
find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
|
||||
search_tour_pois: { stepKey: 'assistant.step.tourPois' },
|
||||
get_city_walk_progress: { stepKey: 'assistant.step.readingTour' },
|
||||
// P7: a route file attached in the chat, read by the server into its tracks.
|
||||
read_tour_file: { stepKey: 'assistant.step.readingTourFile' },
|
||||
},
|
||||
entities: {
|
||||
tour: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared';
|
||||
import { ASSISTANT_TOUR_FILE_EXTENSIONS, LLM_PHOTO_EXTENSIONS } from '@trek/shared';
|
||||
import { type DragEvent, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
@@ -6,7 +6,7 @@ import { assistantScopeOf } from '../../store/assistantChatKey';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/** What the booking import reads (the server checks again), and how big one file may be. */
|
||||
/** What the booking import reads and the route files taken in as tours (the server checks again), and how big one file may be. */
|
||||
const ATTACH_EXTENSIONS: readonly string[] = [
|
||||
'.pdf',
|
||||
'.eml',
|
||||
@@ -16,6 +16,7 @@ const ATTACH_EXTENSIONS: readonly string[] = [
|
||||
'.htm',
|
||||
'.txt',
|
||||
...LLM_PHOTO_EXTENSIONS,
|
||||
...ASSISTANT_TOUR_FILE_EXTENSIONS,
|
||||
];
|
||||
const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
@@ -29,7 +30,7 @@ function hasFiles(e: DragEvent): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a booking file to the chat, from the paperclip, a paste or a drop on
|
||||
* Hands a booking file or a route file (GPX, KML, KMZ) to the chat, from the paperclip, a paste or a drop on
|
||||
* the window. One file at a time, refused here with a toast when it is not
|
||||
* one the import reads or too big, so a wrong file never costs a round trip.
|
||||
* The drop handlers go on the whole chat; `dragging` lights its drop zone.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useOpenTourStore } from '../../store/openTourStore';
|
||||
import { useCanDo } from '../../store/permissionsStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
@@ -41,6 +42,10 @@ function lastSpot(stops: readonly Assignment[] | undefined): { lat: number; lng:
|
||||
* Adding opens the planner's own place form, filled in with the place and on
|
||||
* the day in focus: the traveller sees everything about it and saves it as
|
||||
* any other place, so nothing waits for the chat's open decisions.
|
||||
*
|
||||
* P6: while a saved hike or city walk is open in the planner, "+ Tour" takes
|
||||
* a place into it as a stop: held as a change in the chat like any other,
|
||||
* so the stop is only saved once the traveller applies it.
|
||||
*/
|
||||
export function useAssistantPlaceStrip() {
|
||||
const { tripId, onRevealMap } = useAssistantView();
|
||||
@@ -60,6 +65,8 @@ export function useAssistantPlaceStrip() {
|
||||
);
|
||||
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
|
||||
const openPlaceDraft = useAssistantStore((s) => s.openPlaceDraft);
|
||||
const insertFoundPlace = useAssistantStore((s) => s.insertFoundPlace);
|
||||
const openTour = useOpenTourStore((s) => (s.open?.tripId === tripId ? s.open : null));
|
||||
const point = useCardPoint();
|
||||
|
||||
const focusDay = (days ?? []).find((d) => d.id === focusDayId) ?? null;
|
||||
@@ -85,6 +92,14 @@ export function useAssistantPlaceStrip() {
|
||||
openPlaceDraft(trip.id, place, focusDay?.id ?? null);
|
||||
};
|
||||
|
||||
/** The saved tour a place can be taken into as a stop, null while none is open or nothing can be added. */
|
||||
const tour = canAdd ? openTour : null;
|
||||
|
||||
/** Holds the place as a stop of the open tour, to decide in the chat. */
|
||||
const addToTour = (place: AssistantFoundPlace) => {
|
||||
if (tour) void insertFoundPlace(tripId, place.ref, tour.placeId);
|
||||
};
|
||||
|
||||
/** Shows the place on the map; on the phone the chat steps aside first. */
|
||||
const show = point.show;
|
||||
|
||||
@@ -96,5 +111,5 @@ export function useAssistantPlaceStrip() {
|
||||
[hoverPoint]
|
||||
);
|
||||
|
||||
return { canAdd, categoryOf, distanceOf, open, show, hover };
|
||||
return { canAdd, categoryOf, distanceOf, open, show, hover, tour, addToTour };
|
||||
}
|
||||
|
||||
@@ -41,18 +41,6 @@ function stationSplit<T extends RoutePoint>(point: T & StationParts): { station:
|
||||
return { station, rest: rest as T };
|
||||
}
|
||||
|
||||
/**
|
||||
* A new point at the end of the tour; a loop keeps returning to its start,
|
||||
* so there the point goes in before the way back.
|
||||
*/
|
||||
export function withPointAppended<T extends { lat: number; lng: number; role: string }>(
|
||||
points: readonly T[],
|
||||
point: T
|
||||
): T[] {
|
||||
if (!isClosedLoop(points)) return [...points, { ...point, role: 'end' }];
|
||||
return [...points.slice(0, -1), { ...point, role: 'via' }, points[points.length - 1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* The tour taken back from its last point to its start: one end point on the
|
||||
* start's spot, not a second copy of the start among the stops. `newId` names
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, fireEvent, render, renderHook, screen } from '@testing-library/react';
|
||||
import { withPointAppended } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildCategory, buildPlace } from '../../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
unlinkedPoint,
|
||||
type TourPlannerWaypoint,
|
||||
} from './tourPlannerWaypoints';
|
||||
import { withPointAppended } from './tourRouteActions';
|
||||
import { TripPlacePicker } from './TripPlacePicker';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
import { useTripPlaceWaypoints } from './useTripPlaceWaypoints';
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { nearnessToLine } from '@trek/shared';
|
||||
import { nearnessToLine, withPointAppended } from '@trek/shared';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTripStore } from '../../../store/tripStore';
|
||||
import type { Category, Place } from '../../../types';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import { useLinkedPlaceOf } from '../walk/useWalkSchedule';
|
||||
import { unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
import { withPointAppended } from './tourRouteActions';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
/** A place of the trip as the picker offers it. */
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { isClosedLoop, type PlannableTourType, type TourFixedLeg } from '@trek/shared';
|
||||
import { isClosedLoop, withPointAppended, type PlannableTourType, type TourFixedLeg } from '@trek/shared';
|
||||
import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import { holdsHorseSection } from '../horse/horseLegs';
|
||||
import { unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
import { closeLoop, reverseRoute, toggleFreeLeg, withPointAppended } from './tourRouteActions';
|
||||
import { closeLoop, reverseRoute, toggleFreeLeg } from './tourRouteActions';
|
||||
|
||||
type WaypointChange = TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourPoi, WalkTheme } from '@trek/shared';
|
||||
import { TOUR_POI_CORRIDORS_M } from '@trek/shared';
|
||||
import { placesInTour, TOUR_POI_CORRIDORS_M } from '@trek/shared';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
@@ -30,7 +30,6 @@ import { usePopoverList } from '../../shared/usePopoverList';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { RailCard, TourNotice } from '../tourParts';
|
||||
import { formatPlannedTourDuration } from '../tourPresentation';
|
||||
import { placesInTour } from './poiInsertion';
|
||||
import { SearchNotices } from './TourPoiSearchNotices';
|
||||
import { usePoiInsertion, type InsertionOutcome } from './usePoiInsertion';
|
||||
import { useSelectedPoi, useTourPoiSearch, type TourPoiSearch } from './useTourPoiSearch';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourPoi } from '@trek/shared';
|
||||
import { isClosedLoop } from '@trek/shared';
|
||||
import { insertionPreview, isClosedLoop, straightDetourM, type InsertionSlot } from '@trek/shared';
|
||||
import { ChevronDown, CornerDownRight, Flag, Info, Plus } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
@@ -12,7 +12,6 @@ import { usePopoverList } from '../../shared/usePopoverList';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { useWaypointPlaceNames } from '../planner/useWaypointPlaceNames';
|
||||
import { insertionPreview, straightDetourM, type InsertionSlot } from './poiInsertion';
|
||||
import type { usePoiInsertion } from './usePoiInsertion';
|
||||
|
||||
type Unit = Parameters<typeof formatDistance>[1];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TourPoi } from '@trek/shared';
|
||||
import { chosenAfterId, insertionBlock, insertionSlots, withPointAfter, type TourPoi } from '@trek/shared';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useTourPoiStore, type InsertedTourPoi } from '../../../store/tourPoiStore';
|
||||
@@ -6,7 +6,6 @@ import { randomId } from '../../../utils/randomId';
|
||||
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import type { TourPlannerController, TourPlannerStatus } from '../planner/useTourPlanner';
|
||||
import { chosenAfterId, insertionBlock, insertionSlots, withPointAfter } from './poiInsertion';
|
||||
|
||||
/** The route is planned through and its measures can be read. */
|
||||
const SETTLED: readonly TourPlannerStatus[] = ['ready', 'saved', 'elevation-failed'];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { chosenAfterId, insertionPreview } from '@trek/shared';
|
||||
import { useMemo } from 'react';
|
||||
import { useTourPoiStore } from '../../../store/tourPoiStore';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { chosenAfterId, insertionPreview } from './poiInsertion';
|
||||
import { useSelectedPoi } from './useTourPoiSearch';
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* FE-TOUR-OPEN-001 to FE-TOUR-OPEN-004
|
||||
*
|
||||
* P6: the saved tour the editor has open, as the chat's "+ Tour" reads it:
|
||||
* only a saved hike or city walk the user may edit, on screen, with nothing
|
||||
* unsaved, and nothing once the editor is gone.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { setOpenTour, useOpenTourStore } from '../../../store/openTourStore';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { usePublishOpenTour } from './usePublishOpenTour';
|
||||
|
||||
type Planner = Parameters<typeof usePublishOpenTour>[0];
|
||||
|
||||
const planner = (over: Partial<Planner> = {}): Planner => ({
|
||||
tripId: 4,
|
||||
mode: { type: 'edit-saved', placeId: 64 } as TourPlannerController['mode'],
|
||||
name: 'Herzogstand',
|
||||
tourType: 'hike',
|
||||
hasUnsavedChanges: false,
|
||||
canEdit: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
afterEach(() => setOpenTour(null));
|
||||
|
||||
describe('usePublishOpenTour', () => {
|
||||
it('FE-TOUR-OPEN-001: a saved hike open on screen is the tour a found place can go into', () => {
|
||||
renderHook(() => usePublishOpenTour(planner(), true));
|
||||
expect(useOpenTourStore.getState().open).toEqual({ tripId: 4, placeId: 64, name: 'Herzogstand' });
|
||||
});
|
||||
|
||||
it('FE-TOUR-OPEN-002: none while the editor is off screen, holds unsaved changes, or the user may not edit', () => {
|
||||
const { rerender } = renderHook(({ p, shown }) => usePublishOpenTour(p, shown), {
|
||||
initialProps: { p: planner(), shown: false },
|
||||
});
|
||||
expect(useOpenTourStore.getState().open).toBeNull();
|
||||
rerender({ p: planner({ hasUnsavedChanges: true }), shown: true });
|
||||
expect(useOpenTourStore.getState().open).toBeNull();
|
||||
rerender({ p: planner({ canEdit: false }), shown: true });
|
||||
expect(useOpenTourStore.getState().open).toBeNull();
|
||||
rerender({ p: planner({ tourType: 'walk' }), shown: true });
|
||||
expect(useOpenTourStore.getState().open).toMatchObject({ placeId: 64 });
|
||||
});
|
||||
|
||||
it('FE-TOUR-OPEN-003: a new draft, or a tour type that takes no stop from the chat, is none', () => {
|
||||
const { rerender } = renderHook(({ p }) => usePublishOpenTour(p, true), {
|
||||
initialProps: { p: planner({ mode: { type: 'new-draft' } as TourPlannerController['mode'] }) },
|
||||
});
|
||||
expect(useOpenTourStore.getState().open).toBeNull();
|
||||
rerender({ p: planner({ tourType: 'bike' }) });
|
||||
expect(useOpenTourStore.getState().open).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-OPEN-004: the editor gone, nothing is open any more', () => {
|
||||
const { unmount } = renderHook(() => usePublishOpenTour(planner(), true));
|
||||
expect(useOpenTourStore.getState().open).not.toBeNull();
|
||||
unmount();
|
||||
expect(useOpenTourStore.getState().open).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TOUR_STOP_TYPES } from '@trek/shared';
|
||||
import { useEffect } from 'react';
|
||||
import { setOpenTour } from '../../../store/openTourStore';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
|
||||
type PublishedPlanner = Pick<
|
||||
TourPlannerController,
|
||||
'tripId' | 'mode' | 'name' | 'tourType' | 'hasUnsavedChanges' | 'canEdit'
|
||||
>;
|
||||
|
||||
const STOP_TYPES: readonly string[] = TOUR_STOP_TYPES;
|
||||
|
||||
/**
|
||||
* P6: tells the chat which saved tour the editor has open, so a place the
|
||||
* assistant found can be taken into it with "+ Tour". Only a saved hike or
|
||||
* city walk the user may edit counts, and only while the editor holds nothing
|
||||
* unsaved: the stop is saved on the server, and a draft would then stand
|
||||
* against a newer tour. `shown` is whether the editor is on screen at all.
|
||||
*/
|
||||
export function usePublishOpenTour(planner: PublishedPlanner, shown: boolean): void {
|
||||
const { tripId, mode, name, tourType, hasUnsavedChanges, canEdit } = planner;
|
||||
const placeId = mode.type === 'edit-saved' ? mode.placeId : null;
|
||||
const trip = Number(tripId);
|
||||
const takesStop = shown && placeId !== null && canEdit && !hasUnsavedChanges && STOP_TYPES.includes(tourType);
|
||||
useEffect(() => {
|
||||
setOpenTour(takesStop && placeId !== null ? { tripId: trip, placeId, name } : null);
|
||||
}, [name, placeId, takesStop, trip]);
|
||||
// The editor gone, the chat no longer offers to take anything into it.
|
||||
useEffect(() => () => setOpenTour(null), []);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { usePaddleChecklist } from '../../components/Tours/paddle/usePaddleCheck
|
||||
import { useTrackPaddlePlan } from '../../components/Tours/paddle/useTrackPaddlePlan';
|
||||
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner';
|
||||
import { useTourRevisionWatch } from '../../components/Tours/planner/useTourRevisionWatch';
|
||||
import { usePublishOpenTour } from '../../components/Tours/poi/usePublishOpenTour';
|
||||
import { useSkiClassification } from '../../components/Tours/ski/useSkiClassification';
|
||||
import { useRecordedTrack } from '../../components/Tours/useRecordedTrack';
|
||||
import { useTourGpxImport } from '../../components/Tours/useTourGpxImport';
|
||||
@@ -85,6 +86,7 @@ export function useTourWorkspace(planner: TripPlanner, canPlaceEdit: boolean, ca
|
||||
});
|
||||
|
||||
useTourRevisionWatch(tourPlanner, tours); // P2C-03: a save made elsewhere reaches the open editor
|
||||
usePublishOpenTour(tourPlanner, activeTab === 'tour-planner' && !isMobile); // P6: the chat's "+ Tour" takes a found place into the tour open here
|
||||
const { viewImportedTour, openTourInPlanner } = useTourOpeners(tourPlanner, places, tours);
|
||||
|
||||
// The tours workspace's GPX import, shared by its button and its file drop. One new
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type AssistantAddFoundPlaceRequest,
|
||||
type AssistantChatRequest,
|
||||
type AssistantDecisionsRequest,
|
||||
type AssistantInsertFoundPlaceRequest,
|
||||
type AssistantShareRequest,
|
||||
type AssistantTakeProposalRequest,
|
||||
type BookDocument,
|
||||
@@ -86,6 +87,12 @@ export const assistantRepo = {
|
||||
return assistantAddFoundPlaceResponseSchema.parse(await assistantApi.addFoundPlace(tripId, threadId, body));
|
||||
},
|
||||
|
||||
/** A found place taken into a saved tour as a stop ("+ Tour"), held as a change; no model runs for it. */
|
||||
async insertFoundPlace(tripId: number, threadId: number, body: AssistantInsertFoundPlaceRequest) {
|
||||
requireOnline();
|
||||
return assistantAddFoundPlaceResponseSchema.parse(await assistantApi.insertFoundPlace(tripId, threadId, body));
|
||||
},
|
||||
|
||||
/** A proposal an answer showed as a card ("Plan this"), held as a change; no model runs for it. */
|
||||
async takeProposal(tripId: number, threadId: number, body: AssistantTakeProposalRequest) {
|
||||
requireOnline();
|
||||
|
||||
@@ -228,6 +228,8 @@ interface AssistantState {
|
||||
* thread changed under it, and always skipped under a journey key.
|
||||
*/
|
||||
takeProposal: (key: AssistantChatKey, toolCallId: string) => Promise<HoldOutcome>;
|
||||
/** Takes a found place into the saved tour open in the planner as a stop ("+ Tour"), held as a change to decide. */
|
||||
insertFoundPlace: (key: AssistantChatKey, ref: string, tourId: number) => Promise<HoldOutcome>;
|
||||
}
|
||||
|
||||
/** What came of a change the server was asked to hold without a model turn. */
|
||||
@@ -772,6 +774,10 @@ function compareActions(ctx: Ctx) {
|
||||
holdWithoutTurn(ctx, tripId, (threadId) =>
|
||||
assistantRepo.takeProposal(tripId, threadId, { ...clientContext(), toolCallId })
|
||||
),
|
||||
insertFoundPlace: (tripId: number, ref: string, tourId: number) =>
|
||||
holdWithoutTurn(ctx, tripId, (threadId) =>
|
||||
assistantRepo.insertFoundPlace(tripId, threadId, { ...clientContext(), ref, tourId })
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/** The saved tour open in the planner's editor, as the chat may take a found place into it. */
|
||||
interface OpenTour {
|
||||
tripId: number;
|
||||
placeId: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface OpenTourState {
|
||||
/** Null while no saved hike or city walk is open, or while its editor holds changes not yet saved. */
|
||||
open: OpenTour | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* P6: which saved tour the planner has open, for "+ Tour" on a place the
|
||||
* assistant found. The planner writes it (usePublishOpenTour), the chat's
|
||||
* place cards read it; nothing else is kept here.
|
||||
*/
|
||||
export const useOpenTourStore = create<OpenTourState>(() => ({ open: null }));
|
||||
|
||||
export function setOpenTour(open: OpenTour | null): void {
|
||||
const current = useOpenTourStore.getState().open;
|
||||
const same =
|
||||
current === open ||
|
||||
(!!current &&
|
||||
!!open &&
|
||||
current.tripId === open.tripId &&
|
||||
current.placeId === open.placeId &&
|
||||
current.name === open.name);
|
||||
if (!same) useOpenTourStore.setState({ open });
|
||||
}
|
||||
@@ -64,6 +64,7 @@ const REFUSAL_STATUS: Record<AiRefusalCode, number> = {
|
||||
empty_answer: 502,
|
||||
offline: 503,
|
||||
no_bookings_found: 422,
|
||||
no_tracks_found: 422,
|
||||
import_unavailable: 503,
|
||||
decide_first: 409,
|
||||
too_many_requests: 429,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { AssistantErrorCode } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* A file the user attaches in the trip chat is read by the server itself,
|
||||
* not by the model: a booking file into bookings, a route file into tracks
|
||||
* (P7). Each kind of file has its reader; AssistantService.attach runs it the
|
||||
* same way for every kind: the read step as a call of its own, then one held
|
||||
* change per thing the file holds, decided like any other change.
|
||||
*/
|
||||
|
||||
/** One change a file proposes: the server's write it is held as, and its arguments. */
|
||||
export interface FileProposal {
|
||||
toolName: string;
|
||||
/** What the change runs with, kept on the held call only. */
|
||||
args: Record<string, unknown>;
|
||||
/**
|
||||
* What the message shows of the call where that is less than it runs with
|
||||
* (a GPX text stays on the held call, out of the thread and the history).
|
||||
*/
|
||||
shownArgs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** What reading a file found: the read step's answer, and the changes it proposes. */
|
||||
export interface FileRead {
|
||||
found: Record<string, unknown>;
|
||||
proposals: FileProposal[];
|
||||
}
|
||||
|
||||
export interface AttachedFileReader {
|
||||
/** The name the read step goes by in the thread. */
|
||||
readonly readTool: string;
|
||||
/** The error a file that holds nothing to propose ends the answer with. */
|
||||
readonly emptyCode: AssistantErrorCode;
|
||||
/** Refuses a file nobody here can read, before a turn begins. */
|
||||
ensureReadable(userId: number, file: Express.Multer.File): Promise<void>;
|
||||
/** What the file holds, at most a step's worth. Persists nothing. */
|
||||
propose(userId: number, tripId: number, file: Express.Multer.File): Promise<FileRead>;
|
||||
}
|
||||
@@ -23,13 +23,13 @@ import {
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared';
|
||||
import { ASSISTANT_TOUR_FILE_EXTENSIONS, LLM_PHOTO_EXTENSIONS } from '@trek/shared';
|
||||
|
||||
import type { Response } from 'express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { extname } from 'node:path';
|
||||
|
||||
/** What the assistant reads bookings from: the booking import's formats, plus calendar files. */
|
||||
/** What the assistant reads: the booking import's formats, calendar files, and route files taken in as tours (P7). */
|
||||
const ACCEPTED_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
'.eml',
|
||||
'.pdf',
|
||||
@@ -39,10 +39,11 @@ const ACCEPTED_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
'.htm',
|
||||
'.txt',
|
||||
...LLM_PHOTO_EXTENSIONS,
|
||||
...ASSISTANT_TOUR_FILE_EXTENSIONS,
|
||||
]);
|
||||
|
||||
const PHOTO_LABEL = LLM_PHOTO_EXTENSIONS.map((ext) => ext.slice(1).toUpperCase()).join(', ');
|
||||
const ACCEPTED_LABEL = `EML, PDF, PKPass, ICS, HTML, TXT, ${PHOTO_LABEL}`;
|
||||
const ACCEPTED_LABEL = `EML, PDF, PKPass, ICS, HTML, TXT, ${PHOTO_LABEL}, GPX, KML, KMZ`;
|
||||
|
||||
const UPLOAD = {
|
||||
storage: memoryStorage(),
|
||||
|
||||
@@ -3,7 +3,8 @@ import { BookingImportService } from '../booking-import/booking-import.service';
|
||||
import { DemoService } from '../common/demo.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { MAX_WRITES_PER_STEP } from './assistant-tools.constants';
|
||||
import type { AttachedFileReader, FileRead } from './assistant-attached-file';
|
||||
import { IMPORT_BOOKING_TOOL, MAX_WRITES_PER_STEP, READ_BOOKING_FILE_TOOL } from './assistant-tools.constants';
|
||||
import type { ToolRun } from './assistant-tools.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { bookingImportPreviewItemSchema, LLM_PHOTO_EXTENSIONS, type BookingImportPreviewItem } from '@trek/shared';
|
||||
@@ -24,7 +25,10 @@ const BROKEN = 'This booking can no longer be read.';
|
||||
* and the `reservation_edit` right, taken when it is applied.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AssistantBookingImportService {
|
||||
export class AssistantBookingImportService implements AttachedFileReader {
|
||||
readonly readTool = READ_BOOKING_FILE_TOOL;
|
||||
readonly emptyCode = 'no_bookings_found' as const;
|
||||
|
||||
constructor(
|
||||
private readonly bookingImport: BookingImportService,
|
||||
private readonly trips: TripAccessService,
|
||||
@@ -58,6 +62,15 @@ export class AssistantBookingImportService {
|
||||
return preview.items.slice(0, MAX_WRITES_PER_STEP);
|
||||
}
|
||||
|
||||
/** The bookings of the file as the changes the chat holds, one import_booking each. */
|
||||
async propose(userId: number, _tripId: number, file: Express.Multer.File): Promise<FileRead> {
|
||||
const items = await this.read(userId, file);
|
||||
return {
|
||||
found: { bookings: items.map((item) => item.title) },
|
||||
proposals: items.map((item) => ({ toolName: IMPORT_BOOKING_TOOL, args: item as Record<string, unknown> })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the booking the user applied, from the arguments the server stored
|
||||
* when it proposed it. The answer is shaped like a tool's: the created
|
||||
|
||||
@@ -33,13 +33,17 @@ import { AssistantStreamRegistry } from './assistant-stream.registry';
|
||||
import { ADD_FOUND_PLACE_TOOL, TURNS_PER_MINUTE } from './assistant-tools.constants';
|
||||
import { AssistantToolsService } from './assistant-tools.service';
|
||||
import { AssistantCardContext } from './cards/card-context';
|
||||
import { recordOf } from './cards/card-values';
|
||||
import { defaultAfterKeyOf } from './cards/tour/tour-stop.cards';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
categoryIdForKind,
|
||||
TOUR_STOP_TYPES,
|
||||
type AssistantAddFoundPlaceRequest,
|
||||
type AssistantAddFoundPlaceResponse,
|
||||
type AssistantFoundPlace,
|
||||
type AssistantInsertFoundPlaceRequest,
|
||||
type AssistantMessage,
|
||||
type AssistantMessagePart,
|
||||
} from '@trek/shared';
|
||||
@@ -47,7 +51,22 @@ import {
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/** What a suggestion reads of the trip: its days and places, and the categories a new place is filed in. */
|
||||
const TRIP_READS: ReadonlySet<string> = new Set(['get_trip_summary', 'list_places', 'list_categories']);
|
||||
const TRIP_READS: ReadonlySet<string> = new Set([
|
||||
'get_trip_summary',
|
||||
'list_places',
|
||||
'list_categories',
|
||||
'get_tour_plan',
|
||||
]);
|
||||
|
||||
/** The tour types a found place is taken into as a stop ("+ Tour"), as insert_tour_stop takes them. */
|
||||
const STOP_TOURS: ReadonlySet<unknown> = new Set(TOUR_STOP_TYPES);
|
||||
|
||||
/** What a suggestion reads of the trip: its card, its categories and, for "+ Tour", the tour. */
|
||||
interface TripRead {
|
||||
card: TripCard;
|
||||
categories: CardCategory[];
|
||||
tour: unknown;
|
||||
}
|
||||
|
||||
/** Message rows searched for the place a suggestion names; the history a turn sends the provider. */
|
||||
const SEARCHED_ROWS = 40;
|
||||
@@ -104,6 +123,38 @@ function suggestionArgs(
|
||||
};
|
||||
}
|
||||
|
||||
/** What either suggestion names: the place by its ref, and the user's language and zone for its card. */
|
||||
type SuggestionRequest = Pick<AssistantAddFoundPlaceRequest, 'ref' | 'locale' | 'timeZone'>;
|
||||
|
||||
/** The arguments a suggestion holds, from the place and what was read of the trip; throws to refuse. */
|
||||
type ArgsOf = (place: AssistantFoundPlace, trip: TripRead) => Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* The arguments a found place is taken into a saved tour with ("+ Tour"):
|
||||
* insert_tour_stop's, against the revision read now, after the last point
|
||||
* before the place along the way, with the tour's name for the history. A
|
||||
* tour of another trip, one not read, or one of a type that takes no stop
|
||||
* here is refused.
|
||||
*/
|
||||
export function stopArgs(place: AssistantFoundPlace, tourId: number, read: unknown): Record<string, unknown> {
|
||||
const plan = recordOf(read);
|
||||
const tour = recordOf(plan.tour);
|
||||
if (tour.place_id !== tourId || typeof tour.revision !== 'number') {
|
||||
throw aiRefusal('generic', 'Tour not found in this trip');
|
||||
}
|
||||
if (!STOP_TOURS.has(tour.tour_type)) throw aiRefusal('generic', 'Only a hike or a city walk takes a stop here');
|
||||
const spot = { lat: place.lat, lng: place.lng };
|
||||
const afterKey = defaultAfterKeyOf(plan, spot);
|
||||
return {
|
||||
via: 'insert_tour_stop',
|
||||
placeId: tourId,
|
||||
expected_revision: tour.revision,
|
||||
stop: { ...spot, name: place.name },
|
||||
...(afterKey ? { after_key: afterKey } : {}),
|
||||
tourName: typeof tour.name === 'string' ? tour.name : '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "+ Day 3" on a place a search found: the place is proposed for the day as a
|
||||
* change card, held and decided like one the model proposed, but without a
|
||||
@@ -137,6 +188,39 @@ export class AssistantFoundPlacesService {
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
req: AssistantAddFoundPlaceRequest,
|
||||
): Promise<AssistantAddFoundPlaceResponse> {
|
||||
return this.hold(userId, tripId, threadId, req, null, (place, trip) => {
|
||||
if (!trip.card.days.some((day) => day.id === req.dayId)) throw aiRefusal('generic', 'Day not found in this trip');
|
||||
return suggestionArgs(place, req.dayId, trip.card, trip.categories);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* "+ Tour" on a place a search found, while a saved hike or city walk is
|
||||
* open in the planner: the place is proposed as a stop of that tour, after
|
||||
* the last point before it along the way, against the revision the tour has
|
||||
* now. Held and decided like "+ Day 3"; applying it runs insert_tour_stop
|
||||
* with the user's own rights, which checks the tour again.
|
||||
*/
|
||||
async insertIntoTour(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
req: AssistantInsertFoundPlaceRequest,
|
||||
): Promise<AssistantAddFoundPlaceResponse> {
|
||||
return this.hold(userId, tripId, threadId, req, req.tourId, (place, trip) =>
|
||||
stopArgs(place, req.tourId, trip.tour),
|
||||
);
|
||||
}
|
||||
|
||||
/** Holds the change `argsOf` builds for a found place, under the rules both suggestions share. */
|
||||
private async hold(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
req: SuggestionRequest,
|
||||
tourId: number | null,
|
||||
argsOf: ArgsOf,
|
||||
): Promise<AssistantAddFoundPlaceResponse> {
|
||||
if (!(await this.threads.findOwned(threadId, userId, tripId))) {
|
||||
throw aiRefusal('not_found', 'Conversation not found');
|
||||
@@ -149,7 +233,7 @@ export class AssistantFoundPlacesService {
|
||||
throw aiRefusal('busy', 'The assistant is already answering in another window');
|
||||
}
|
||||
try {
|
||||
await this.propose(userId, tripId, threadId, req);
|
||||
await this.propose(userId, tripId, threadId, req, tourId, argsOf);
|
||||
} finally {
|
||||
await this.streams.release(userId, controller);
|
||||
}
|
||||
@@ -160,20 +244,21 @@ export class AssistantFoundPlacesService {
|
||||
userId: number,
|
||||
tripId: number,
|
||||
threadId: number,
|
||||
req: AssistantAddFoundPlaceRequest,
|
||||
req: SuggestionRequest,
|
||||
tourId: number | null,
|
||||
argsOf: ArgsOf,
|
||||
): Promise<void> {
|
||||
if ((await this.toolCalls.listOpen(threadId)).length > 0) {
|
||||
throw aiRefusal('decide_first', 'Decide on the changes that are waiting first');
|
||||
}
|
||||
const place = foundPlace(await this.messages.listLatest(threadId, SEARCHED_ROWS), req.ref);
|
||||
if (!place) throw aiRefusal('generic', 'This place is not among the search results of the conversation');
|
||||
const { card, categories } = await this.tripOf(userId, tripId);
|
||||
if (!card.days.some((day) => day.id === req.dayId)) throw aiRefusal('generic', 'Day not found in this trip');
|
||||
const trip = await this.tripOf(userId, tripId, tourId);
|
||||
const args = argsOf(place, trip);
|
||||
|
||||
const id = randomUUID();
|
||||
const args = suggestionArgs(place, req.dayId, card, categories);
|
||||
const names = new Map(categories.map((category): [number, string] => [category.id, category.name]));
|
||||
const ctx = this.cardContext.contextOf(card, names, req.locale, todayIn(req.timeZone, new Date()));
|
||||
const names = new Map(trip.categories.map((category): [number, string] => [category.id, category.name]));
|
||||
const ctx = this.cardContext.contextOf(trip.card, names, req.locale, todayIn(req.timeZone, new Date()));
|
||||
const [{ preview }] = await this.cardContext.describeProposed(userId, tripId, ctx, [
|
||||
{ id, toolName: ADD_FOUND_PLACE_TOOL, args },
|
||||
]);
|
||||
@@ -208,16 +293,21 @@ export class AssistantFoundPlacesService {
|
||||
});
|
||||
}
|
||||
|
||||
/** The trip card and the categories, read through the trip tools with the user's rights. */
|
||||
private async tripOf(userId: number, tripId: number): Promise<{ card: TripCard; categories: CardCategory[] }> {
|
||||
/**
|
||||
* The trip card and the categories, read through the trip tools with the
|
||||
* user's rights, and with `tourId` the saved tour a stop goes into.
|
||||
*/
|
||||
private async tripOf(userId: number, tripId: number, tourId: number | null): Promise<TripRead> {
|
||||
const session = await this.tools.open(userId, tripId, TRIP_READS);
|
||||
try {
|
||||
const summary = await session.read('get_trip_summary');
|
||||
const places = await session.read('list_places');
|
||||
const listed = await session.read('list_categories');
|
||||
const tour = tourId === null ? null : await session.read('get_tour_plan', { placeId: tourId });
|
||||
return {
|
||||
card: buildTripCard(tripId, userId, summary.ok ? summary.value : null, places.ok ? places.value : null, null),
|
||||
categories: categoriesFrom(listed.ok ? listed.value : null),
|
||||
tour: tour?.ok ? tour.value : null,
|
||||
};
|
||||
} finally {
|
||||
await session.close();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AssistantMessagePart } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The server's own tour steps as the model is told of them (P6, P7): a found
|
||||
* place the user took into a tour with "+ Tour", a route file read, a track
|
||||
* of it taken in. Like the booking file's notes in assistant-history.ts, in
|
||||
* plain words, since none of them is a call the model made.
|
||||
*/
|
||||
|
||||
type ToolCallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>;
|
||||
|
||||
function quoted(value: unknown): string {
|
||||
return `"${typeof value === 'string' && value !== '' ? value : '?'}"`;
|
||||
}
|
||||
|
||||
/** What became of a decided change, in the words of what it was. */
|
||||
function decided(part: ToolCallPart, what: string, done: string, waiting: string): string {
|
||||
if (part.approval?.approved === true) return part.isError ? `${what} could not be ${done}.` : `${what} was ${done}.`;
|
||||
if (part.approval?.approved === false) return `The user discarded ${what}.`;
|
||||
if (part.approval?.resolution) return `${what} was not ${done}; the user moved on without deciding.`;
|
||||
return waiting;
|
||||
}
|
||||
|
||||
/** A found place the user took into a saved tour as a stop. */
|
||||
export function tourStopOutcome(part: ToolCallPart): string {
|
||||
const stop = part.args.stop && typeof part.args.stop === 'object' ? (part.args.stop as Record<string, unknown>) : {};
|
||||
const what = `${quoted(stop.name)} as a stop of the tour ${quoted(part.args.tourName)}`;
|
||||
const waiting = `The user picked ${what} from the search results; it waits for them to add or discard it.`;
|
||||
return decided(part, what, 'added', waiting);
|
||||
}
|
||||
|
||||
/** A route file read into its tracks. */
|
||||
export function tourFileReadNote(part: ToolCallPart): string {
|
||||
const file = quoted(part.args.name);
|
||||
if (part.isError) return `TREK could not read the attached route file ${file}.`;
|
||||
const result = (part.result ?? {}) as { tracks?: unknown };
|
||||
const tracks = Array.isArray(result.tracks) ? result.tracks : [];
|
||||
if (tracks.length === 0) return `TREK read the attached route file ${file} and found no track or route in it.`;
|
||||
return `TREK read the attached route file ${file} and found ${tracks.length} track(s): ${JSON.stringify(tracks)}.`;
|
||||
}
|
||||
|
||||
/** One track of a route file taken in as a hike. */
|
||||
export function tourFileOutcome(part: ToolCallPart): string {
|
||||
const what = `The track ${quoted(part.args.track_name)} of ${quoted(part.args.file_name)}`;
|
||||
const waiting = `${what} waits for the user to take it in as a hike or discard it.`;
|
||||
return decided(part, what, 'taken in as a hike', waiting);
|
||||
}
|
||||
@@ -4,13 +4,16 @@ import type { AssistantToolCallRow } from '../../db/repositories/AssistantToolCa
|
||||
import { parseDbTimestamp, utcSuffix } from '../../db/types';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
import type { AiChatMessage, AiToolCall } from '../ai-connections/ai-chat-client.interface';
|
||||
import { tourFileOutcome, tourFileReadNote, tourStopOutcome } from './assistant-history-tours';
|
||||
import { fitToSize } from './assistant-output';
|
||||
import {
|
||||
ADD_FOUND_PLACE_TOOL,
|
||||
APPROVAL_TTL_MS,
|
||||
ASSISTANT_SERVER_TOOLS,
|
||||
IMPORT_BOOKING_TOOL,
|
||||
IMPORT_TOUR_FILE_TOOL,
|
||||
READ_BOOKING_FILE_TOOL,
|
||||
READ_TOUR_FILE_TOOL,
|
||||
TAKE_PROPOSAL_TOOL,
|
||||
} from './assistant-tools.constants';
|
||||
import {
|
||||
@@ -105,6 +108,14 @@ function takenProposalOutcome(part: ToolCallPart): string {
|
||||
return `The user took the proposed ${what} ${name} to add it; it waits for them to add or discard it.`;
|
||||
}
|
||||
|
||||
/** The note of a server step about tours (P6, P7), null for any other. */
|
||||
function tourStepNote(part: ToolCallPart): string | null {
|
||||
if (part.toolName === ADD_FOUND_PLACE_TOOL && part.args.via === 'insert_tour_stop') return tourStopOutcome(part);
|
||||
if (part.toolName === READ_TOUR_FILE_TOOL) return tourFileReadNote(part);
|
||||
if (part.toolName === IMPORT_TOUR_FILE_TOOL) return tourFileOutcome(part);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's own steps of a message (reading an attached file, the bookings
|
||||
* it found) as one note in plain words. They are no tools the provider knows,
|
||||
@@ -114,7 +125,10 @@ function takenProposalOutcome(part: ToolCallPart): string {
|
||||
export function serverStepsNote(calls: readonly ToolCallPart[]): string {
|
||||
const lines: string[] = [];
|
||||
for (const part of calls) {
|
||||
if (part.toolName === READ_BOOKING_FILE_TOOL) {
|
||||
const tourNote = tourStepNote(part);
|
||||
if (tourNote !== null) {
|
||||
lines.push(tourNote);
|
||||
} else if (part.toolName === READ_BOOKING_FILE_TOOL) {
|
||||
const result = (part.result ?? {}) as { bookings?: unknown };
|
||||
const titles = Array.isArray(result.bookings) ? result.bookings.map(quoted) : [];
|
||||
const file = quoted(part.args.name);
|
||||
|
||||
@@ -241,6 +241,7 @@ const CORE_TOOL_GROUPS: ToolGroups = {
|
||||
'TourActivitiesMcp',
|
||||
'TourActivityStatsMcp',
|
||||
'TourWalkImportMcp',
|
||||
'TourStopsMcp',
|
||||
'CollectionToursMcp',
|
||||
'PaddleRoutingMcp',
|
||||
'SkiRoutingMcp',
|
||||
|
||||
@@ -112,6 +112,18 @@ export const READ_BOOKING_FILE_TOOL = 'read_booking_file';
|
||||
/** A booking read from an attached file, held as a change card like any write. */
|
||||
export const IMPORT_BOOKING_TOOL = 'import_booking';
|
||||
|
||||
/**
|
||||
* P7: the step that reads a route file the user attached (GPX, KML, KMZ),
|
||||
* run by the server itself like the booking read, and each track or route it
|
||||
* found held as a change that takes it in as a tour through the model's
|
||||
* import_tour_gpx, named in its `via` argument.
|
||||
*/
|
||||
export const READ_TOUR_FILE_TOOL = 'read_tour_file';
|
||||
export const IMPORT_TOUR_FILE_TOOL = 'import_tour_file';
|
||||
|
||||
/** What a held track of a route file runs as. */
|
||||
const TOUR_FILE_VIA = 'import_tour_gpx';
|
||||
|
||||
/**
|
||||
* A place from the search results of an answer, suggested for a day by the
|
||||
* user ("+ Day 3"). Held as a change card like any write, without a model turn.
|
||||
@@ -143,7 +155,12 @@ export const REMEMBER_TOOL = 'remember_for_trip';
|
||||
export const PREVIEW_BOOK_PAGE_TOOL = 'preview_book_page';
|
||||
|
||||
/** The model's tools a found place is added through: a new place for the day, or one the trip has already. */
|
||||
const FOUND_PLACE_VIA: ReadonlySet<string> = new Set(['create_and_assign_place', 'assign_place_to_day']);
|
||||
const FOUND_PLACE_VIA: ReadonlySet<string> = new Set([
|
||||
'create_and_assign_place',
|
||||
'assign_place_to_day',
|
||||
// "+ Tour": the place taken into the saved tour open in the planner as a stop (P6).
|
||||
'insert_tour_stop',
|
||||
]);
|
||||
|
||||
/** The model's tools a taken proposal is created through: a hike, a city walk. */
|
||||
const PROPOSAL_VIA: ReadonlySet<string> = new Set(['create_hike', 'create_city_walk']);
|
||||
@@ -159,6 +176,7 @@ const PROPOSAL_VIA: ReadonlySet<string> = new Set(['create_hike', 'create_city_w
|
||||
*/
|
||||
export const ASSISTANT_SERVER_WRITE_TOOLS: Readonly<Record<string, AssistantWriteTool>> = {
|
||||
[IMPORT_BOOKING_TOOL]: { verbKey: 'importBooking', entity: 'reservation' },
|
||||
[IMPORT_TOUR_FILE_TOOL]: { verbKey: 'importTour', entity: 'tour' },
|
||||
// The card names the verb of the `via` tool; these are its fallbacks.
|
||||
[ADD_FOUND_PLACE_TOOL]: { verbKey: 'addPlaceToDay', entity: 'place', quiet: true },
|
||||
[TAKE_PROPOSAL_TOOL]: { verbKey: 'addTour', entity: 'tour', quiet: true },
|
||||
@@ -175,12 +193,22 @@ export function writeToolOf(toolName: string, args: Record<string, unknown>): st
|
||||
const via = typeof args.via === 'string' ? args.via : '';
|
||||
if (toolName === ADD_FOUND_PLACE_TOOL) return FOUND_PLACE_VIA.has(via) ? via : toolName;
|
||||
if (toolName === TAKE_PROPOSAL_TOOL) return PROPOSAL_VIA.has(via) ? via : toolName;
|
||||
if (toolName === IMPORT_TOUR_FILE_TOOL) return via === TOUR_FILE_VIA ? via : toolName;
|
||||
return toolName;
|
||||
}
|
||||
|
||||
/** The arguments a found place or a taken proposal runs its `via` tool with: what that tool takes, nothing else. */
|
||||
export function foundPlaceArgs(args: Record<string, unknown>): Record<string, unknown> {
|
||||
if (args.via === 'assign_place_to_day') return { dayId: args.dayId, placeId: args.placeId };
|
||||
if (args.via === TOUR_FILE_VIA) {
|
||||
// The track's own name stays with the card; the import takes the file's text, its name and the type.
|
||||
const { gpx, file_name, tour_type } = args;
|
||||
return { gpx, ...(file_name !== undefined ? { file_name } : {}), tour_type };
|
||||
}
|
||||
if (args.via === 'insert_tour_stop') {
|
||||
const { placeId, expected_revision, stop, after_key } = args;
|
||||
return { placeId, expected_revision, stop, ...(after_key !== undefined ? { after_key } : {}) };
|
||||
}
|
||||
const { via: _via, ...rest } = args;
|
||||
return rest;
|
||||
}
|
||||
@@ -188,6 +216,7 @@ export function foundPlaceArgs(args: Record<string, unknown>): Record<string, un
|
||||
/** Calls the server makes on its own: replayed to the model as a note, never as a call of an unknown tool. */
|
||||
export const ASSISTANT_SERVER_TOOLS: ReadonlySet<string> = new Set([
|
||||
READ_BOOKING_FILE_TOOL,
|
||||
READ_TOUR_FILE_TOOL,
|
||||
...Object.keys(ASSISTANT_SERVER_WRITE_TOOLS),
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { aiRefusal } from '../ai-connections/ai-connections.constants';
|
||||
import { TourFileReadService, type TourFileTrack } from '../tours/tour-file-read.service';
|
||||
import type { AttachedFileReader, FileProposal, FileRead } from './assistant-attached-file';
|
||||
import { IMPORT_TOUR_FILE_TOOL, MAX_WRITES_PER_STEP, READ_TOUR_FILE_TOOL } from './assistant-tools.constants';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
/** The longest file name an import keeps, as import_tour_gpx takes it. */
|
||||
const FILE_NAME_CHARS = 200;
|
||||
|
||||
/** Kilometres to a hundred metres: enough for the model to tell the tracks apart. */
|
||||
function roundedKm(km: number): number {
|
||||
return Math.round(km * 10) / 10;
|
||||
}
|
||||
|
||||
/** A track as the read step tells the model of it: what it is, and why it is not proposed where it is not. */
|
||||
function trackFact(track: TourFileTrack) {
|
||||
return {
|
||||
name: track.name,
|
||||
distance_km: roundedKm(track.distance_km),
|
||||
points: track.point_count,
|
||||
has_heights: track.has_heights,
|
||||
...(track.duplicate ? { skipped: 'already_in_trip' } : {}),
|
||||
...(track.gpx === null ? { skipped: 'too_large' } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* P7: a GPX, KML or KMZ file the user attached in the trip chat, read into
|
||||
* its tracks and routes (TourFileReadService, the Tours import's reader) and
|
||||
* proposed as one held change per track, which takes it in as a hike through
|
||||
* import_tour_gpx with the user's rights when they apply it (the type can be
|
||||
* changed in the planner, or by asking the assistant afterwards). A track the
|
||||
* trip has already, or one longer than one import takes, is named in the read
|
||||
* step and not proposed. Only the held call keeps the track's GPX text; the
|
||||
* thread shows its name and the file's.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AssistantTourFileService implements AttachedFileReader {
|
||||
readonly readTool = READ_TOUR_FILE_TOOL;
|
||||
readonly emptyCode = 'no_tracks_found' as const;
|
||||
|
||||
constructor(
|
||||
private readonly files: TourFileReadService,
|
||||
private readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
/** Refuses a route file where tours are off: nothing could take it in. */
|
||||
async ensureReadable(_userId: number, _file: Express.Multer.File): Promise<void> {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.TOURS))) {
|
||||
throw aiRefusal('import_unavailable', 'Tours are not enabled on this server');
|
||||
}
|
||||
}
|
||||
|
||||
async propose(_userId: number, tripId: number, file: Express.Multer.File): Promise<FileRead> {
|
||||
const read = await this.files.read(tripId, file.buffer, file.originalname);
|
||||
const fileName = file.originalname.trim().slice(0, FILE_NAME_CHARS) || 'import.gpx';
|
||||
const taken = read.tracks.filter((track) => !track.duplicate && track.gpx !== null).slice(0, MAX_WRITES_PER_STEP);
|
||||
const proposals = taken.map((track): FileProposal => {
|
||||
const shown = { via: 'import_tour_gpx', file_name: fileName, tour_type: 'hike', track_name: track.name };
|
||||
return { toolName: IMPORT_TOUR_FILE_TOOL, args: { ...shown, gpx: track.gpx }, shownArgs: shown };
|
||||
});
|
||||
return {
|
||||
found: {
|
||||
tracks: read.tracks.map(trackFact),
|
||||
// A GPX file that could not be cut into its tracks comes in as one import of all of them.
|
||||
...(read.whole ? { whole_file: true } : {}),
|
||||
...(read.left_out ? { left_out: read.left_out } : {}),
|
||||
...(read.tracks.length > taken.length ? { proposed: taken.length } : {}),
|
||||
},
|
||||
proposals,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
AssistantAddFoundPlaceDto,
|
||||
AssistantChatDto,
|
||||
AssistantDecisionsDto,
|
||||
AssistantInsertFoundPlaceDto,
|
||||
AssistantShareDto,
|
||||
AssistantStopDto,
|
||||
AssistantTakeProposalDto,
|
||||
@@ -133,6 +134,23 @@ export class AssistantController {
|
||||
return this.foundPlaces.add(user.id, Number(tripId), idParam(threadId), body);
|
||||
}
|
||||
|
||||
/**
|
||||
* "+ Tour" on a place a search found, while a saved tour is open: a change card that takes it in as a
|
||||
* stop, no model turn. It changes a tour, so it asks for the right the tour routes ask for.
|
||||
*/
|
||||
@Post('threads/:threadId/found-places/tour')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('place_edit')
|
||||
@ResponseContract(assistantAddFoundPlaceResponseSchema)
|
||||
insertFoundPlace(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('threadId') threadId: string,
|
||||
@Body() body: AssistantInsertFoundPlaceDto,
|
||||
) {
|
||||
return this.foundPlaces.insertIntoTour(user.id, Number(tripId), idParam(threadId), body);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Plan this" on a hike or a city walk an answer proposed: a change card that creates it, no model turn.
|
||||
* It holds a new tour, so it asks for the right the tour routes ask for.
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
assistantAttachmentRequestSchema,
|
||||
assistantChatRequestSchema,
|
||||
assistantDecisionsRequestSchema,
|
||||
assistantInsertFoundPlaceRequestSchema,
|
||||
assistantRenderUploadRequestSchema,
|
||||
assistantShareRequestSchema,
|
||||
assistantStopRequestSchema,
|
||||
@@ -21,6 +22,7 @@ export class AssistantAddFoundPlaceDto extends createZodDto(assistantAddFoundPla
|
||||
export class AssistantAttachmentDto extends createZodDto(assistantAttachmentRequestSchema) {}
|
||||
export class AssistantChatDto extends createZodDto(assistantChatRequestSchema) {}
|
||||
export class AssistantDecisionsDto extends createZodDto(assistantDecisionsRequestSchema) {}
|
||||
export class AssistantInsertFoundPlaceDto extends createZodDto(assistantInsertFoundPlaceRequestSchema) {}
|
||||
export class AssistantRenderUploadDto extends createZodDto(assistantRenderUploadRequestSchema) {}
|
||||
export class AssistantShareDto extends createZodDto(assistantShareRequestSchema) {}
|
||||
export class AssistantStopDto extends createZodDto(assistantStopRequestSchema) {}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { PhotoSignalsModule } from '../photos/photo-signals.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { TourFilesModule } from '../tours/tour-files.module';
|
||||
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
|
||||
import { AssistantAttachmentsController } from './assistant-attachments.controller';
|
||||
import { AssistantBookContext } from './assistant-book-context';
|
||||
@@ -29,6 +30,7 @@ import { AssistantScopes } from './assistant-scope';
|
||||
import { AssistantShareService } from './assistant-share.service';
|
||||
import { AssistantStreamRegistry, InMemoryAssistantStreamRegistry } from './assistant-stream.registry';
|
||||
import { AssistantToolsService } from './assistant-tools.service';
|
||||
import { AssistantTourFileService } from './assistant-tour-file.service';
|
||||
import { AssistantVision } from './assistant-vision';
|
||||
import { AssistantController } from './assistant.controller';
|
||||
import { AssistantService } from './assistant.service';
|
||||
@@ -74,6 +76,8 @@ import { Module } from '@nestjs/common';
|
||||
SchedulingModule,
|
||||
SettingsModule,
|
||||
TripMembershipModule,
|
||||
// P7: a route file attached in the chat, read into its tracks.
|
||||
TourFilesModule,
|
||||
MikroOrmModule.forFeature([AssistantThreads, AssistantMessages, AssistantToolCalls]),
|
||||
],
|
||||
controllers: [
|
||||
@@ -89,6 +93,7 @@ import { Module } from '@nestjs/common';
|
||||
AssistantService,
|
||||
AssistantToolsService,
|
||||
AssistantBookingImportService,
|
||||
AssistantTourFileService,
|
||||
AssistantFoundPlacesService,
|
||||
AssistantProposalsService,
|
||||
AssistantShareService,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { withRequestContext } from '../database/request-context';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { runAgentLoop, type AgentStore, type HeldCall, type StepRecord } from './assistant-agent';
|
||||
import type { AttachedFileReader } from './assistant-attached-file';
|
||||
import { AssistantBookingImportService } from './assistant-booking-import.service';
|
||||
import { mentionItems } from './assistant-cards.helpers';
|
||||
import { todayIn } from './assistant-context';
|
||||
@@ -48,13 +49,13 @@ import {
|
||||
ASSISTANT_SERVER_WRITE_TOOLS,
|
||||
foundPlaceArgs,
|
||||
IMPORT_BOOKING_TOOL,
|
||||
READ_BOOKING_FILE_TOOL,
|
||||
REMEMBER_TOOL,
|
||||
TURNS_PER_MINUTE,
|
||||
UNDO_WINDOW_MS,
|
||||
writeToolOf,
|
||||
} from './assistant-tools.constants';
|
||||
import { AssistantToolsService, type AssistantToolSession, type ToolRun } from './assistant-tools.service';
|
||||
import { AssistantTourFileService } from './assistant-tour-file.service';
|
||||
import { readTripCard } from './assistant-trip-scope';
|
||||
import { unchangedSinceApplied, undoSnapshotOf, undoTargetsOf } from './assistant-undo.helpers';
|
||||
import { AssistantVision } from './assistant-vision';
|
||||
@@ -78,8 +79,8 @@ import type {
|
||||
AssistantThreadListResponse,
|
||||
AssistantThreadResponse,
|
||||
AssistantThreadSummary,
|
||||
BookingImportPreviewItem,
|
||||
} from '@trek/shared';
|
||||
import { isAssistantTourFile } from '@trek/shared';
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
@@ -171,6 +172,7 @@ export class AssistantService {
|
||||
private readonly usage: AiUsageService,
|
||||
private readonly tools: AssistantToolsService,
|
||||
private readonly bookings: AssistantBookingImportService,
|
||||
private readonly tourFiles: AssistantTourFileService,
|
||||
private readonly settings: SettingsService,
|
||||
private readonly rateLimit: RateLimitService,
|
||||
private readonly streams: AssistantStreamRegistry,
|
||||
@@ -251,9 +253,9 @@ export class AssistantService {
|
||||
|
||||
/**
|
||||
* A file the user attached, in a thread of theirs on this trip or (no
|
||||
* threadId) a new one, answered as a stream: the file is read into bookings
|
||||
* and each is proposed as a change card, applied or discarded like any
|
||||
* other. No model is asked here; the decision on the cards starts the next
|
||||
* threadId) a new one, answered as a stream: the file is read into bookings,
|
||||
* or a route file (GPX, KML, KMZ) into its tracks, and each is proposed as a
|
||||
* change card, applied or discarded like any other. No model is asked here; the decision on the cards starts the next
|
||||
* answer. Refused like a chat turn, and also when nothing here reads files.
|
||||
*
|
||||
* @txIndependent the opening rows are one transaction and the proposals
|
||||
@@ -267,7 +269,8 @@ export class AssistantService {
|
||||
channel: TurnChannel,
|
||||
): Promise<void> {
|
||||
await this.admit(userId);
|
||||
await this.bookings.ensureReadable(userId, file);
|
||||
const reader: AttachedFileReader = isAssistantTourFile(file.originalname) ? this.tourFiles : this.bookings;
|
||||
await reader.ensureReadable(userId, file);
|
||||
const connection = await this.connections.resolveForUser(userId);
|
||||
await this.withinDailyLimit(userId, connection);
|
||||
await this.claimed(userId, channel.signal, async (signal) => {
|
||||
@@ -283,7 +286,7 @@ export class AssistantService {
|
||||
try {
|
||||
const { locale, timeZone } = req;
|
||||
const turn = { userId, tripId, threadId: begun.threadId, rowId: begun.rowId, locale, timeZone, name };
|
||||
await this.proposeBookings(turn, file, w, signal);
|
||||
await this.proposeFromFile(turn, file, reader, w, signal);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
logError(`[assistant] attachment in thread ${begun.threadId} failed: ${reason}`);
|
||||
@@ -539,16 +542,18 @@ export class AssistantService {
|
||||
|
||||
/**
|
||||
* Reads the attached file and proposes what it found, in the answer's first
|
||||
* row: the read step, then one held `import_booking` change per booking,
|
||||
* each with its card. A file without bookings ends the answer with
|
||||
* `no_bookings_found`; one that cannot be read, with `generic`.
|
||||
* row: the read step, then one held change per booking (`import_booking`)
|
||||
* or track (`import_tour_file`), each with its card. A file without any
|
||||
* ends the answer with the reader's code; one that cannot be read, with
|
||||
* `generic`.
|
||||
*
|
||||
* @txIndependent the row is written once the file was read, in a
|
||||
* transaction of its own.
|
||||
*/
|
||||
private async proposeBookings(
|
||||
private async proposeFromFile(
|
||||
turn: { userId: number; tripId: number; threadId: number; rowId: number; name: string } & AssistantClientCtx,
|
||||
file: Express.Multer.File,
|
||||
reader: AttachedFileReader,
|
||||
w: UiStreamWriter,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
@@ -557,17 +562,17 @@ export class AssistantService {
|
||||
const readPart = (result: unknown, isError = false): AssistantMessagePart => ({
|
||||
type: 'tool-call',
|
||||
toolCallId: readId,
|
||||
toolName: READ_BOOKING_FILE_TOOL,
|
||||
toolName: reader.readTool,
|
||||
args: readArgs,
|
||||
result,
|
||||
...(isError ? { isError: true } : {}),
|
||||
});
|
||||
const fieldsOf = (parts: AssistantMessagePart[]) => ({ parts: encodeParts(parts), providerState: null });
|
||||
w.startStep();
|
||||
w.toolInput(readId, READ_BOOKING_FILE_TOOL, readArgs);
|
||||
let items: BookingImportPreviewItem[];
|
||||
w.toolInput(readId, reader.readTool, readArgs);
|
||||
let read: Awaited<ReturnType<AttachedFileReader['propose']>>;
|
||||
try {
|
||||
items = await this.inContext(() => this.bookings.read(turn.userId, file));
|
||||
read = await this.inContext(() => reader.propose(turn.userId, turn.tripId, file));
|
||||
} catch (err) {
|
||||
logError(`[assistant] reading ${turn.name} failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
await this.saveRow(turn.threadId, turn.rowId, fieldsOf([readPart(FILE_UNREADABLE, true)]), 'error');
|
||||
@@ -576,27 +581,25 @@ export class AssistantService {
|
||||
w.finish('error');
|
||||
return;
|
||||
}
|
||||
const found = { bookings: items.map((item) => item.title) };
|
||||
const { found, proposals } = read;
|
||||
if (signal.aborted) {
|
||||
await this.saveRow(turn.threadId, turn.rowId, fieldsOf([readPart(found)]), 'aborted');
|
||||
w.abort();
|
||||
return;
|
||||
}
|
||||
w.toolOutput(readId, found);
|
||||
if (items.length === 0) {
|
||||
if (proposals.length === 0) {
|
||||
await this.saveRow(turn.threadId, turn.rowId, fieldsOf([readPart(found)]), 'error');
|
||||
w.finishStep();
|
||||
w.error('no_bookings_found');
|
||||
w.error(reader.emptyCode);
|
||||
w.finish('error');
|
||||
return;
|
||||
}
|
||||
const card = await readTripCard(this.tools, turn.userId, turn.tripId);
|
||||
const ctx = this.cardContext.contextOf(card, new Map(), turn.locale, todayIn(turn.timeZone, new Date()));
|
||||
const proposed = items.map((item) => ({
|
||||
id: randomUUID(),
|
||||
toolName: IMPORT_BOOKING_TOOL,
|
||||
args: item as Record<string, unknown>,
|
||||
}));
|
||||
const proposed = proposals.map((proposal) => ({ ...proposal, id: randomUUID() }));
|
||||
// The thread shows a call as the reader shows it; the held call keeps everything it runs with.
|
||||
const shown = new Map<string, Record<string, unknown>>(proposed.map((c) => [c.id, c.shownArgs ?? c.args]));
|
||||
const described = await this.cardContext.describeProposed(turn.userId, turn.tripId, ctx, proposed);
|
||||
const held: HeldCall[] = described.map((change) => ({ ...change, providerCallId: `import_${change.id}` }));
|
||||
const parts: AssistantMessagePart[] = [
|
||||
@@ -605,14 +608,14 @@ export class AssistantService {
|
||||
type: 'tool-call',
|
||||
toolCallId: change.id,
|
||||
toolName: change.toolName,
|
||||
args: change.args,
|
||||
args: shown.get(change.id) ?? change.args,
|
||||
approval: { id: change.id },
|
||||
})),
|
||||
...held.map((change): AssistantMessagePart => ({ type: 'data-trek-change', data: change.preview })),
|
||||
];
|
||||
await this.holdChanges(turn.threadId, turn.rowId, fieldsOf(parts), held);
|
||||
for (const change of held) {
|
||||
w.toolInput(change.id, change.toolName, change.args);
|
||||
w.toolInput(change.id, change.toolName, shown.get(change.id) ?? change.args);
|
||||
w.approvalRequest(change.id);
|
||||
w.change(change.preview);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TOUR_DESCRIBERS } from './tour-describe';
|
||||
import { tourListCard } from './tour-lists.cards';
|
||||
import { proposalCard, savedTourCard } from './tour-route.cards';
|
||||
import { cityWalkCard, paddleCard, skiCard } from './tour-sport.cards';
|
||||
import { TOUR_STOP_CARDS } from './tour-stop.cards';
|
||||
import { DELETE_CREATED_TOUR, DELETE_IMPORTED_TOUR } from './tour-undo';
|
||||
import { describeVisit, loadVisit, SET_VISIT_UNDO } from './tour-visit.cards';
|
||||
import { TOUR_WRITE_CARDS } from './tour-writes.cards';
|
||||
@@ -87,6 +88,7 @@ export const TOUR_CARDS: DomainCards = {
|
||||
describe: TOUR_DESCRIBERS.import_tour_gpx,
|
||||
undo: DELETE_IMPORTED_TOUR,
|
||||
},
|
||||
...TOUR_STOP_CARDS,
|
||||
take_over_tour: {
|
||||
write: { verbKey: 'addTour', entity: 'tour' },
|
||||
describe: TOUR_DESCRIBERS.take_over_tour,
|
||||
|
||||
@@ -95,6 +95,8 @@ export const TOUR_DESCRIBERS = {
|
||||
take_over_tour: (_ctx, args) => ({ subject: text(args.name), rows: new Rows(), dayId: null, placeIds: [] }),
|
||||
import_tour_gpx: (_ctx, args) => {
|
||||
const rows = new Rows().add('type', text(args.tour_type));
|
||||
return { subject: text(args.file_name) || gpxName(args.gpx) || 'GPX', rows, dayId: null, placeIds: [] };
|
||||
// A track of a file attached in the chat names itself (track_name); the model's import names the file.
|
||||
const subject = text(args.track_name) || text(args.file_name) || gpxName(args.gpx) || 'GPX';
|
||||
return { subject, rows, dayId: null, placeIds: [] };
|
||||
},
|
||||
} satisfies Readonly<Record<string, (ctx: PreviewContext, args: ToolArgs) => Described>>;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { CardReader, Described, PreviewContext, ToolArgs, ToolCards, UndoCall, UndoSnapshot } from '../card-types';
|
||||
import { argId, readKey, recordOf, type Row } from '../card-values';
|
||||
import { Rows, text } from '../preview-format';
|
||||
import { rowsIn } from './tour-card-values';
|
||||
import { alongRouteM, defaultAfterId, type InsertionPoint } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* P6: a place taken into a saved hike or city walk as one stop
|
||||
* (insert_tour_stop), and a stop taken out again (remove_tour_stop). Each is
|
||||
* the other's way back: an insert is undone by taking out the stop it added,
|
||||
* a removal by putting the stop back after the point it followed, both while
|
||||
* the tour is still at the revision the change left it at. The card names the
|
||||
* tour, the stop and where it goes, between which two points, read from the
|
||||
* tour as saved now.
|
||||
*/
|
||||
|
||||
/** The saved tour a held change is about, read once for its card. */
|
||||
async function loadStopTour(args: ToolArgs, read: CardReader): Promise<unknown> {
|
||||
const placeId = argId(args, 'placeId');
|
||||
return placeId === null ? null : read('get_tour_plan', { placeId });
|
||||
}
|
||||
|
||||
/** The points of the tour as read, as the planner's insertion rule takes them. */
|
||||
function pointsOf(plan: Row): Array<InsertionPoint & { name: string }> {
|
||||
return rowsIn(plan.waypoints).flatMap((point, index) => {
|
||||
if (typeof point.lat !== 'number' || typeof point.lng !== 'number') return [];
|
||||
return [
|
||||
{
|
||||
id: typeof point.key === 'string' ? point.key : `#${index}`,
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
role: typeof point.role === 'string' ? point.role : 'via',
|
||||
fixedLeg: point.fixed_leg ? (point.fixed_leg as InsertionPoint['fixedLeg']) : null,
|
||||
...(point.shaping === true ? { shaping: true } : {}),
|
||||
name: text(point.name) || String(index + 1),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** The saved line in brief, for where along it a stop lies. */
|
||||
function previewOf(plan: Row): Array<[number, number]> | null {
|
||||
const preview = recordOf(plan.line).preview;
|
||||
if (!Array.isArray(preview)) return null;
|
||||
const line = preview.flatMap((point): Array<[number, number]> => {
|
||||
const [lat, lng] = Array.isArray(point) ? point : [];
|
||||
return typeof lat === 'number' && typeof lng === 'number' ? [[lat, lng]] : [];
|
||||
});
|
||||
return line.length > 1 ? line : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The key of the point a stop at `spot` goes after where none is named: the
|
||||
* last point before it along the saved line, as insert_tour_stop picks it.
|
||||
*/
|
||||
export function defaultAfterKeyOf(plan: Row, spot: { lat: number; lng: number }): string | null {
|
||||
const points = pointsOf(plan);
|
||||
const line = previewOf(plan);
|
||||
const id = defaultAfterId(points, line, alongRouteM(points, line, spot));
|
||||
return id !== null && !id.startsWith('#') ? id : null;
|
||||
}
|
||||
|
||||
/** "Parking → Hut": the two points a stop goes between, or the one it follows as the new end. */
|
||||
function betweenOf(plan: Row, args: ToolArgs): string {
|
||||
const points = pointsOf(plan);
|
||||
const stop = recordOf(args.stop);
|
||||
let afterId = typeof args.after_key === 'string' ? args.after_key : null;
|
||||
if (afterId === null && typeof stop.lat === 'number' && typeof stop.lng === 'number') {
|
||||
afterId = defaultAfterKeyOf(plan, { lat: stop.lat, lng: stop.lng });
|
||||
}
|
||||
const index = points.findIndex((point) => point.id === afterId);
|
||||
const after = points[index];
|
||||
if (!after) return '';
|
||||
const next = points.slice(index + 1).find((point) => point.shaping !== true);
|
||||
return next ? `${after.name} → ${next.name}` : `${after.name} →`;
|
||||
}
|
||||
|
||||
function tourName(ctx: PreviewContext, args: ToolArgs, plan: Row): string {
|
||||
const placeId = argId(args, 'placeId');
|
||||
return text(recordOf(plan.tour).name) || (placeId === null ? '' : (ctx.card.places.get(placeId)?.name ?? ''));
|
||||
}
|
||||
|
||||
function described(ctx: PreviewContext, args: ToolArgs, plan: Row, rows: Rows, verbKey: Described['verbKey']) {
|
||||
const placeId = argId(args, 'placeId');
|
||||
return {
|
||||
subject: tourName(ctx, args, plan),
|
||||
rows,
|
||||
dayId: null,
|
||||
placeIds: placeId !== null && ctx.card.places.has(placeId) ? [placeId] : [],
|
||||
verbKey,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the tour is still at the revision the change left it at, read before the way back. */
|
||||
function atRevision(result: unknown, snapshot: UndoSnapshot, call?: UndoCall): boolean {
|
||||
if (!call) return false;
|
||||
const plan = recordOf(snapshot.reads.get(readKey('get_tour_plan', { placeId: call.args.placeId })));
|
||||
const revision = recordOf(recordOf(result).tour).revision;
|
||||
return typeof revision === 'number' && recordOf(plan.tour).revision === revision;
|
||||
}
|
||||
|
||||
const readsPlan = (args: ToolArgs) => [{ tool: 'get_tour_plan', args: { placeId: args.placeId } }];
|
||||
|
||||
const insertStop: ToolCards<unknown> = {
|
||||
write: { verbKey: 'addTourStop', entity: 'tour' },
|
||||
load: loadStopTour,
|
||||
describe: (ctx, args, loaded) => {
|
||||
const plan = recordOf(loaded);
|
||||
const rows = new Rows().add('name', text(recordOf(args.stop).name)).add('position', betweenOf(plan, args));
|
||||
return described(ctx, args, plan, rows, 'addTourStop');
|
||||
},
|
||||
undo: {
|
||||
tool: 'remove_tour_stop',
|
||||
args: (_args, result) => {
|
||||
const answer = recordOf(result);
|
||||
const tour = recordOf(answer.tour);
|
||||
const key = recordOf(answer.stop).key;
|
||||
if (typeof key !== 'string' || typeof tour.revision !== 'number' || typeof tour.place_id !== 'number') {
|
||||
return null;
|
||||
}
|
||||
return { placeId: tour.place_id, key, expected_revision: tour.revision };
|
||||
},
|
||||
reads: readsPlan,
|
||||
unchanged: atRevision,
|
||||
},
|
||||
};
|
||||
|
||||
const removeStop: ToolCards<unknown> = {
|
||||
write: { verbKey: 'removeTourStop', entity: 'tour' },
|
||||
load: loadStopTour,
|
||||
describe: (ctx, args, loaded) => {
|
||||
const plan = recordOf(loaded);
|
||||
const stop = rowsIn(plan.waypoints).find((point) => point.key === args.key);
|
||||
return described(ctx, args, plan, new Rows().add('name', text(stop?.name)), 'removeTourStop');
|
||||
},
|
||||
undo: {
|
||||
tool: 'insert_tour_stop',
|
||||
args: (_args, result) => {
|
||||
const answer = recordOf(result);
|
||||
const tour = recordOf(answer.tour);
|
||||
const removed = recordOf(answer.removed);
|
||||
if (typeof tour.revision !== 'number' || typeof tour.place_id !== 'number') return null;
|
||||
if (typeof removed.lat !== 'number' || typeof removed.lng !== 'number') return null;
|
||||
const stop: Row = { lat: removed.lat, lng: removed.lng };
|
||||
if (typeof removed.name === 'string') stop.name = removed.name;
|
||||
if (typeof removed.poi_category === 'string') stop.poi_category = removed.poi_category;
|
||||
const after = typeof removed.after_key === 'string' ? { after_key: removed.after_key } : {};
|
||||
return { placeId: tour.place_id, expected_revision: tour.revision, stop, ...after };
|
||||
},
|
||||
reads: readsPlan,
|
||||
unchanged: atRevision,
|
||||
},
|
||||
};
|
||||
|
||||
export const TOUR_STOP_CARDS: Readonly<Record<string, ToolCards>> = {
|
||||
insert_tour_stop: insertStop,
|
||||
remove_tour_stop: removeStop,
|
||||
};
|
||||
@@ -91,6 +91,7 @@ export const MANAGED_ROUTE_ALLOW_LIST: string[] = [
|
||||
'AssistantController.chat',
|
||||
'AssistantController.decide',
|
||||
'AssistantController.deleteThread',
|
||||
'AssistantController.insertFoundPlace',
|
||||
'AssistantController.share',
|
||||
'AssistantController.stop',
|
||||
'AssistantController.takeProposal',
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { PlaceImportService } from '../place-import/place-import.service';
|
||||
import type { PreparedGpxPlace } from '../place-import/place-import.types';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { readTourFile } from './tour-file-import.helpers';
|
||||
import { gpxOfLine, splitGpxTracks } from './tour-file-split.helpers';
|
||||
import { computeTourMetrics, parseRouteGeometry } from './tours.helpers';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TOUR_GPX_TEXT_MAX, type TourFileLeftOut } from '@trek/shared';
|
||||
|
||||
/** One track or route of an attached file, ready for import_tour_gpx. */
|
||||
export interface TourFileTrack {
|
||||
name: string;
|
||||
/** Its own GPX text; null where it is longer than one import takes. */
|
||||
gpx: string | null;
|
||||
point_count: number;
|
||||
distance_km: number;
|
||||
has_heights: boolean;
|
||||
/** A track already in the trip under the same name and start, which the import would skip. */
|
||||
duplicate: boolean;
|
||||
}
|
||||
|
||||
/** What an attached route file holds, track by track. */
|
||||
interface TourFileTracks {
|
||||
tracks: TourFileTrack[];
|
||||
/**
|
||||
* True where a GPX file could not be cut into its tracks: its one entry is
|
||||
* then the whole file, which comes in as one import of all its tracks.
|
||||
*/
|
||||
whole: boolean;
|
||||
/** What a KML or KMZ file holds besides its lines (pins, areas), never taken in. */
|
||||
left_out?: TourFileLeftOut;
|
||||
}
|
||||
|
||||
const GPX_OPTIONS = { importWaypoints: false, importRoutes: true, importTracks: true } as const;
|
||||
|
||||
function isGpxName(fileName: string): boolean {
|
||||
const ext = fileName.toLowerCase().split('.').pop();
|
||||
return ext !== 'kml' && ext !== 'kmz';
|
||||
}
|
||||
|
||||
/**
|
||||
* P7: a route file the user attached in the assistant's chat, read the way
|
||||
* the Tours import reads it (readTourFile: GPX by its tracks and routes, KML
|
||||
* and KMZ by their lines) and cut into one GPX text per track, so each one is
|
||||
* proposed and taken in as a tour of its own and taken back on its own.
|
||||
* Reads only: nothing is stored here, the import is import_tour_gpx's.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourFileReadService {
|
||||
constructor(
|
||||
private readonly placeImport: PlaceImportService,
|
||||
private readonly places: PlacesService,
|
||||
) {}
|
||||
|
||||
async read(tripId: number, buffer: Buffer, fileName: string): Promise<TourFileTracks> {
|
||||
const { rows, leftOut } = await readTourFile(this.placeImport, buffer, fileName);
|
||||
if (rows.length === 0) return { tracks: [], whole: false };
|
||||
const duplicates = await this.places.gpxDuplicates(String(tripId), rows);
|
||||
const texts = isGpxName(fileName) ? this.cutGpx(buffer.toString('utf8'), rows.length, fileName) : null;
|
||||
const extra = leftOut ? { left_out: leftOut } : {};
|
||||
if (isGpxName(fileName) && !texts) {
|
||||
return { tracks: [this.wholeFile(buffer.toString('utf8'), rows, fileName)], whole: true, ...extra };
|
||||
}
|
||||
const tracks = rows.map((row, index) => {
|
||||
const own = texts?.[index] ?? gpxOfLine(row.name, row.description, parseRouteGeometry(row.routeGeometry));
|
||||
return this.trackOf(row, own, duplicates[index] ?? false);
|
||||
});
|
||||
return { tracks, whole: false, ...extra };
|
||||
}
|
||||
|
||||
/** A GPX text cut into its tracks; null where a piece does not read back as exactly the one it should. */
|
||||
private cutGpx(text: string, count: number, fileName: string): string[] | null {
|
||||
const pieces = splitGpxTracks(text);
|
||||
if (pieces.length !== count) return null;
|
||||
const readable = pieces.every((piece) => {
|
||||
const rows = this.placeImport.readGpx(Buffer.from(piece, 'utf8'), { ...GPX_OPTIONS, defaultName: fileName });
|
||||
return rows.length === 1;
|
||||
});
|
||||
return readable ? pieces : null;
|
||||
}
|
||||
|
||||
private trackOf(row: PreparedGpxPlace, gpx: string, duplicate: boolean): TourFileTrack {
|
||||
const line = parseRouteGeometry(row.routeGeometry);
|
||||
const metrics = computeTourMetrics(line);
|
||||
return {
|
||||
name: row.name,
|
||||
gpx: gpx.length <= TOUR_GPX_TEXT_MAX ? gpx : null,
|
||||
point_count: line.length,
|
||||
distance_km: metrics.distanceKm,
|
||||
has_heights: metrics.hasElevation,
|
||||
duplicate,
|
||||
};
|
||||
}
|
||||
|
||||
/** The whole GPX file as one entry, named by the file and measured over all its tracks. */
|
||||
private wholeFile(text: string, rows: readonly PreparedGpxPlace[], fileName: string): TourFileTrack {
|
||||
const lines = rows.map((row) => parseRouteGeometry(row.routeGeometry));
|
||||
const metrics = lines.map((line) => computeTourMetrics(line));
|
||||
return {
|
||||
name: fileName,
|
||||
gpx: text.length <= TOUR_GPX_TEXT_MAX ? text : null,
|
||||
point_count: lines.reduce((sum, line) => sum + line.length, 0),
|
||||
distance_km: metrics.reduce((sum, metric) => sum + metric.distanceKm, 0),
|
||||
has_heights: metrics.every((metric) => metric.hasElevation),
|
||||
duplicate: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { escapeHtml, type GeometryPoint } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* P7: a route file the user attached in the chat, cut into one GPX text per
|
||||
* track or route, so each comes in as a tour of its own and can be taken back
|
||||
* on its own. A GPX file is cut out of its own text, so every point keeps
|
||||
* what it carried (times, heights, extensions); a KML or KMZ line, which
|
||||
* holds no times, is written as GPX from its points.
|
||||
*/
|
||||
|
||||
/** Where every `<trk>` and `<rte>` element of a GPX text starts and ends, in order. */
|
||||
function trackBlocks(gpx: string): Array<{ from: number; to: number }> {
|
||||
const blocks: Array<{ from: number; to: number }> = [];
|
||||
let at = 0;
|
||||
for (;;) {
|
||||
const trk = openingAt(gpx, 'trk', at);
|
||||
const rte = openingAt(gpx, 'rte', at);
|
||||
const next = [trk, rte].filter((found) => found >= 0).sort((a, b) => a - b)[0];
|
||||
if (next === undefined) return blocks;
|
||||
const tag = next === trk ? 'trk' : 'rte';
|
||||
const close = gpx.indexOf(`</${tag}>`, next);
|
||||
if (close < 0) return blocks;
|
||||
const to = close + tag.length + 3;
|
||||
blocks.push({ from: next, to });
|
||||
at = to;
|
||||
}
|
||||
}
|
||||
|
||||
/** The first `<tag>` or `<tag ...>` from `at`, not a longer tag such as `<trkseg>`. */
|
||||
function openingAt(text: string, tag: string, at: number): number {
|
||||
let from = at;
|
||||
for (;;) {
|
||||
const found = text.indexOf(`<${tag}`, from);
|
||||
if (found < 0) return -1;
|
||||
const after = text.charAt(found + tag.length + 1);
|
||||
if (after === '>' || after === ' ' || after === '\n' || after === '\r' || after === '\t') return found;
|
||||
from = found + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One GPX text per track and route of a GPX file: the file's head (its
|
||||
* declaration, the `<gpx>` element and its metadata) around each element.
|
||||
* Empty where the file holds none the cut can find (a namespace prefix, say).
|
||||
*/
|
||||
export function splitGpxTracks(gpx: string): string[] {
|
||||
const blocks = trackBlocks(gpx);
|
||||
const first = blocks[0];
|
||||
if (!first) return [];
|
||||
const head = gpx.slice(0, first.from);
|
||||
// The head ends before the first waypoint as well: an import never takes loose points.
|
||||
const wpt = openingAt(head, 'wpt', 0);
|
||||
const kept = wpt >= 0 ? head.slice(0, wpt) : head;
|
||||
return blocks.map(({ from, to }) => `${kept}${gpx.slice(from, to)}\n</gpx>\n`);
|
||||
}
|
||||
|
||||
/** A line read from a KML or KMZ file as a GPX text of one track. */
|
||||
export function gpxOfLine(name: string, description: string | null, line: readonly GeometryPoint[]): string {
|
||||
const points = line
|
||||
.map(([lat, lng, ele]) => {
|
||||
const height = typeof ele === 'number' && Number.isFinite(ele) ? `<ele>${ele}</ele>` : '';
|
||||
return `<trkpt lat="${lat}" lon="${lng}">${height}</trkpt>`;
|
||||
})
|
||||
.join('');
|
||||
const desc = description ? `<desc>${escapeHtml(description)}</desc>` : '';
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
||||
'<gpx version="1.1" creator="TREK" xmlns="http://www.topografix.com/GPX/1/1">\n' +
|
||||
`<trk><name>${escapeHtml(name)}</name>${desc}<trkseg>${points}</trkseg></trk>\n</gpx>\n`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PlaceImportModule } from '../place-import/place-import.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { TourFileReadService } from './tour-file-read.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* P7: a route file read into tours without storing anything, for the
|
||||
* assistant's chat (a GPX, KML or KMZ file the user attaches). It reads the
|
||||
* file as the Tours import does, through PlaceImportModule's readers, and
|
||||
* marks the tracks the trip has already through PlacesModule; the import
|
||||
* itself stays import_tour_gpx's. A module of its own so the assistant takes
|
||||
* this one reader without the whole Tours module.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PlaceImportModule, PlacesModule],
|
||||
providers: [TourFileReadService],
|
||||
exports: [TourFileReadService],
|
||||
})
|
||||
export class TourFilesModule {}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
alongRouteM,
|
||||
defaultAfterId,
|
||||
haversineMetres,
|
||||
insertionBlock,
|
||||
isClosedLoop,
|
||||
withPointAfter,
|
||||
type GeometryPoint,
|
||||
type HikeStop,
|
||||
type InsertionPoint,
|
||||
type TourStop,
|
||||
type TourWaypoint,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* P6: the pure half of a stop taken into a saved tour or out of it (see
|
||||
* TourStopsService). The points of the tour are read as the planner reads
|
||||
* them for a found place (poi-insertion.ts in @trek/shared), so the stop goes
|
||||
* where the planner would put it and a leg drawn by hand is never cut.
|
||||
*/
|
||||
|
||||
/** How near a saved point must lie to the stop to be the one the save made of it, in metres. */
|
||||
const SAME_SPOT_M = 30;
|
||||
|
||||
/** A saved point as the insertion rule reads it, keeping the point itself. */
|
||||
export interface StopPoint extends InsertionPoint {
|
||||
saved: TourWaypoint;
|
||||
}
|
||||
|
||||
/** The id a point goes by: its key, or its place in the order where it has none yet. */
|
||||
function idOf(point: TourWaypoint, index: number): string {
|
||||
return point.key ?? `#${index}`;
|
||||
}
|
||||
|
||||
export function stopPointsOf(waypoints: readonly TourWaypoint[]): StopPoint[] {
|
||||
return waypoints.map((saved, index) => ({
|
||||
id: idOf(saved, index),
|
||||
lat: saved.lat,
|
||||
lng: saved.lng,
|
||||
role: saved.role,
|
||||
fixedLeg: saved.fixed_leg ?? null,
|
||||
...(saved.shaping ? { shaping: true } : {}),
|
||||
saved,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Why the stop cannot go after the point named, in words an assistant can pass on. */
|
||||
const BLOCKED: Readonly<Record<'fixed' | 'free' | 'none', string>> = {
|
||||
fixed: 'The leg after that point is a fixed line (a via ferrata, a ferry or a portage): a stop there would cut it.',
|
||||
free: 'The leg after that point is drawn by hand: a stop there would redraw it. Put the stop in the planner.',
|
||||
none: 'Nothing comes after that point: it is the way back of a loop. Name the point before it.',
|
||||
};
|
||||
|
||||
/**
|
||||
* The point the stop goes after: the one named by its key, or the last point
|
||||
* before the stop along the saved line. A string says why it cannot go in.
|
||||
*/
|
||||
export function afterPointOf(
|
||||
points: readonly StopPoint[],
|
||||
line: readonly GeometryPoint[],
|
||||
stop: Pick<TourStop, 'lat' | 'lng'>,
|
||||
afterKey: string | undefined,
|
||||
): StopPoint | string {
|
||||
if (afterKey !== undefined) {
|
||||
const named = points.find((point) => point.id === afterKey);
|
||||
if (!named) return `This tour has no point with the key ${afterKey}: read it again with get_tour_plan.`;
|
||||
if (named.shaping) return 'That point only shapes the line and is no stop: name a stop of the tour.';
|
||||
const block = insertionBlock(points, named.id);
|
||||
return block ? BLOCKED[block] : named;
|
||||
}
|
||||
const route = line.length > 1 ? line : null;
|
||||
const id = defaultAfterId(points, route, alongRouteM(points, route, stop));
|
||||
const chosen = points.find((point) => point.id === id);
|
||||
return chosen ?? 'No leg of this tour can take a stop: every one is drawn by hand or a fixed line.';
|
||||
}
|
||||
|
||||
/** A saved point as a hike's update takes it: its spot, and its name, kind and stage end where it has them. */
|
||||
function hikeStopOf(point: TourWaypoint): HikeStop {
|
||||
return {
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
...(point.name ? { name: point.name } : {}),
|
||||
...(point.poi_category ? { poi_category: point.poi_category } : {}),
|
||||
...(point.stage_end ? { stage_end: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The stop as a new saved point would carry it; the server gives it its key on the save. */
|
||||
function waypointOfStop(stop: TourStop): TourWaypoint {
|
||||
return {
|
||||
lat: stop.lat,
|
||||
lng: stop.lng,
|
||||
role: 'via',
|
||||
sequence: 0,
|
||||
...(stop.name ? { name: stop.name } : {}),
|
||||
...(stop.poi_category ? { poi_category: stop.poi_category } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A hike's points with the stop after `afterId`, as update_tour takes them; null where it cannot go there. */
|
||||
export function hikePointsWithStop(points: readonly StopPoint[], afterId: string, stop: TourStop): HikeStop[] | null {
|
||||
const added: StopPoint = { id: '#new', lat: stop.lat, lng: stop.lng, role: 'via', saved: waypointOfStop(stop) };
|
||||
const next = withPointAfter(points, afterId, added);
|
||||
return next ? next.map((point) => hikeStopOf(point.saved)) : null;
|
||||
}
|
||||
|
||||
/** Why a stop cannot be taken out of these points, or null when it can. */
|
||||
export function removalRefusal(points: readonly StopPoint[], key: string): string | null {
|
||||
const index = points.findIndex((point) => point.id === key);
|
||||
if (index < 0) return `This tour has no point with the key ${key}: read it again with get_tour_plan.`;
|
||||
if (points.length <= 2) return 'A tour keeps at least its start and its end: this point cannot be taken out.';
|
||||
if (index === 0) return 'The start of a tour is not taken out here: change the start in the planner.';
|
||||
if (isClosedLoop(points) && index === points.length - 1)
|
||||
return 'The way back of a loop is not taken out here: use the planner.';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A hike's points without the one keyed `key`. */
|
||||
export function hikePointsWithout(points: readonly StopPoint[], key: string): HikeStop[] {
|
||||
return points.filter((point) => point.id !== key).map((point) => hikeStopOf(point.saved));
|
||||
}
|
||||
|
||||
/** The key the save gave the stop: the saved point nearest its spot, within a few metres of it. */
|
||||
export function keyNear(waypoints: readonly TourWaypoint[], spot: { lat: number; lng: number }): string | null {
|
||||
let best: { key: string; metres: number } | null = null;
|
||||
for (const point of waypoints) {
|
||||
if (!point.key) continue;
|
||||
const metres = haversineMetres(point.lat, point.lng, spot.lat, spot.lng);
|
||||
if (metres <= SAME_SPOT_M && (!best || metres < best.metres)) best = { key: point.key, metres };
|
||||
}
|
||||
return best?.key ?? null;
|
||||
}
|
||||
|
||||
/** The key of the point before the one keyed `key`, where it has one. */
|
||||
export function keyBefore(points: readonly StopPoint[], key: string): string | null {
|
||||
const index = points.findIndex((point) => point.id === key);
|
||||
return index > 0 ? (points[index - 1]?.saved.key ?? null) : null;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_WRITE, errorResult, ok, type McpContext } from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { TourStopsService } from './tour-stops.service';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
idSchema,
|
||||
tourStopInsertRequestSchema,
|
||||
tourStopRemoveRequestSchema,
|
||||
type TourStopInsertRequest,
|
||||
type TourStopRemoveRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** A refusal for a tour this trip does not have, the same text every tool of the tours answers. */
|
||||
function tourNotFound(err: unknown) {
|
||||
if (err instanceof NotFoundException) return errorResult('Tour not found.');
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* P6: a place taken into a saved hike or city walk as one stop, and taken out
|
||||
* again. Both write with 'place_edit' on the trip, as the planner's save over
|
||||
* REST asks (ToursController's update) and as update_tour and
|
||||
* save_tour_change ask, whose paths they save through; the trip hears of the
|
||||
* change as from those (tours:changed and place:updated).
|
||||
*/
|
||||
@McpController()
|
||||
export class TourStopsMcp {
|
||||
constructor(
|
||||
private readonly stops: TourStopsService,
|
||||
private readonly access: TripAccessService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
// Read by the addon gate of every tool here.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'insert_tour_stop',
|
||||
description:
|
||||
'Take a place into a saved hike or city walk as one stop, against the revision it was read at (expected_revision is tour.revision from get_tour_plan or list_tours): a hut, a café, a viewpoint the traveller found along it, from search_tour_pois or any search. stop is where it is, its name and on a hike its poi_category, on a city walk its opening_hours. With after_key (the key of a point from get_tour_plan) the stop comes after that point; left out, after the last point before it along the route, as the planner puts a found place. A leg drawn by hand or a fixed line (a via ferrata, a ferry, a portage) is never cut, and nothing goes after the way back of a loop: such a place is refused with the reason, name another point. A hike plans its way anew through the points, as update_tour does (where it cannot be planned nothing is saved and the error says why), and keeps its stages and the links of its points; a city walk saves the stop as a new station as save_tour_change does, the visits of its other stations kept. Any other type, and a recorded track without points of its own, is refused: change those in the planner. A tour changed since that revision is refused as a conflict: read it again. The answer: tour (place_id, name, tour_type, the new revision, distance_km and minutes as now stored, route_pending), stop (its key, where it is, after_key and after_name, the point it follows) and before (distance_km and minutes as stored before). Take it out again with remove_tour_stop. Needs place editing permission on the trip. Take a place in only after the traveller said yes.',
|
||||
inputSchema: { tripId: idSchema, ...tourStopInsertRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async insertTourStop({ tripId, ...request }: { tripId: number } & TourStopInsertRequest, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
try {
|
||||
return ok(await this.stops.insert(ctx.userId, tripId, request));
|
||||
} catch (err: unknown) {
|
||||
return tourNotFound(err);
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'remove_tour_stop',
|
||||
description:
|
||||
'Take one stop out of a saved hike or city walk by its key (from get_tour_plan or insert_tour_stop), against the revision it was read at (expected_revision). A hike plans its way anew through the points left, as update_tour does; a city walk removes the station as save_tour_change does, and refuses a station protected, or visited or skipped on a day. The start of a tour, the way back of a loop and a tour of two points keep their points: those are refused. Any other type is refused: change it in the planner. A tour changed since that revision is refused as a conflict: read it again. The answer: tour (place_id, name, tour_type, the new revision, distance_km and minutes as now stored, route_pending) and removed (the stop with its key, where it was, its name and after_key, the point it followed). Needs place editing permission on the trip. Take a stop out only after the traveller said yes.',
|
||||
inputSchema: { tripId: idSchema, ...tourStopRemoveRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async removeTourStop({ tripId, ...request }: { tripId: number } & TourStopRemoveRequest, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
try {
|
||||
return ok(await this.stops.remove(ctx.userId, tripId, request));
|
||||
} catch (err: unknown) {
|
||||
return tourNotFound(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { badRequest } from '../common/domain-error';
|
||||
import { HikePlanService } from './hike-plan.service';
|
||||
import { proposalIdOf } from './tour-change-proposal';
|
||||
import { TourChangeSaveService } from './tour-change-save.service';
|
||||
import { tourRevisionConflict } from './tour-revision';
|
||||
import {
|
||||
afterPointOf,
|
||||
hikePointsWithStop,
|
||||
hikePointsWithout,
|
||||
keyBefore,
|
||||
keyNear,
|
||||
removalRefusal,
|
||||
stopPointsOf,
|
||||
type StopPoint,
|
||||
} from './tour-stops.helpers';
|
||||
import { ToursService } from './tours.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
TOUR_STOP_TYPES,
|
||||
type HikeStop,
|
||||
type TourChange,
|
||||
type TourDetailResponse,
|
||||
type TourStopInsertRequest,
|
||||
type TourStopInsertResult,
|
||||
type TourStopRemoveRequest,
|
||||
type TourStopRemoveResult,
|
||||
} from '@trek/shared';
|
||||
|
||||
type StopTourType = (typeof TOUR_STOP_TYPES)[number];
|
||||
|
||||
/** The tour as an answer names it, from the tour as saved now. */
|
||||
function tourOf(detail: TourDetailResponse, tourType: StopTourType): TourStopInsertResult['tour'] {
|
||||
const { tour } = detail;
|
||||
return {
|
||||
place_id: tour.place_id,
|
||||
name: tour.name,
|
||||
tour_type: tourType,
|
||||
revision: tour.revision ?? 1,
|
||||
distance_km: tour.distance ?? null,
|
||||
minutes: tour.duration ?? null,
|
||||
route_pending: tour.route_pending === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** The type of a tour a stop is taken in or out of here; any other is refused with where to change it. */
|
||||
function stopTypeOf(detail: TourDetailResponse): StopTourType {
|
||||
const type = detail.tour.tour_type;
|
||||
if (type === 'hike' || type === 'walk') {
|
||||
if (detail.tour.has_waypoints !== true) {
|
||||
throw badRequest(
|
||||
'This tour is a recorded track without points of its own: a stop would plan it over. Plan it in the planner first.',
|
||||
);
|
||||
}
|
||||
return type;
|
||||
}
|
||||
throw badRequest('Only a hike or a city walk takes a stop here: change any other tour in the planner.');
|
||||
}
|
||||
|
||||
/**
|
||||
* P6: a place taken into a saved hike or city walk as one stop, and the same
|
||||
* stop taken out again, which is how the assistant's insert is undone. Where
|
||||
* the stop goes is the planner's own rule for a found place (poi-insertion.ts
|
||||
* in @trek/shared): after the point named or after the last one before it
|
||||
* along the route, never into a leg drawn by hand or a fixed line.
|
||||
*
|
||||
* Nothing here writes on its own: a hike is saved through update_tour's path
|
||||
* (HikePlanService.update: its way planned anew through the points, its
|
||||
* stages and the links of its points kept), a city walk through
|
||||
* save_tour_change's (TourChangeSaveService.save: one recorded step, the
|
||||
* visits of the other stations kept). Both refuse a tour changed since the
|
||||
* revision it was read at, and both tell the trip about the change.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourStopsService {
|
||||
constructor(
|
||||
private readonly tours: ToursService,
|
||||
private readonly hikes: HikePlanService,
|
||||
private readonly walkChanges: TourChangeSaveService,
|
||||
) {}
|
||||
|
||||
/** The tour as saved, of a type that takes a stop here, at the revision the request read it at. */
|
||||
private async current(tripId: number, placeId: number, expected: number) {
|
||||
const detail = await this.tours.getTour(String(tripId), String(placeId));
|
||||
const type = stopTypeOf(detail);
|
||||
const revision = detail.tour.revision ?? null;
|
||||
if (revision !== expected) throw tourRevisionConflict(revision);
|
||||
return { detail, type, points: stopPointsOf(detail.waypoints) };
|
||||
}
|
||||
|
||||
async insert(userId: number, tripId: number, request: TourStopInsertRequest): Promise<TourStopInsertResult> {
|
||||
const { placeId, stop } = request;
|
||||
const { detail, type, points } = await this.current(tripId, placeId, request.expected_revision);
|
||||
const line = await this.tours.savedLine(tripId, placeId);
|
||||
const after = afterPointOf(points, line, stop, request.after_key);
|
||||
if (typeof after === 'string') throw badRequest(after);
|
||||
const saved =
|
||||
type === 'hike'
|
||||
? await this.saveHike(
|
||||
userId,
|
||||
tripId,
|
||||
placeId,
|
||||
request.expected_revision,
|
||||
hikePointsWithStop(points, after.id, stop),
|
||||
)
|
||||
: await this.saveWalk(userId, tripId, placeId, request.expected_revision, {
|
||||
op: 'add',
|
||||
after_key: this.walkKeyOf(after),
|
||||
station: {
|
||||
lat: stop.lat,
|
||||
lng: stop.lng,
|
||||
...(stop.name ? { name: stop.name } : {}),
|
||||
...(stop.opening_hours ? { opening_hours: stop.opening_hours } : {}),
|
||||
},
|
||||
});
|
||||
return {
|
||||
tour: tourOf(saved, type),
|
||||
stop: {
|
||||
key: keyNear(saved.waypoints, stop),
|
||||
lat: stop.lat,
|
||||
lng: stop.lng,
|
||||
name: stop.name ?? null,
|
||||
poi_category: stop.poi_category ?? null,
|
||||
after_key: after.saved.key ?? null,
|
||||
after_name: after.saved.name ?? null,
|
||||
},
|
||||
before: { distance_km: detail.tour.distance ?? null, minutes: detail.tour.duration ?? null },
|
||||
};
|
||||
}
|
||||
|
||||
async remove(userId: number, tripId: number, request: TourStopRemoveRequest): Promise<TourStopRemoveResult> {
|
||||
const { placeId, key } = request;
|
||||
const { type, points } = await this.current(tripId, placeId, request.expected_revision);
|
||||
const refusal = removalRefusal(points, key);
|
||||
const gone = points.find((point) => point.id === key);
|
||||
if (refusal || !gone) throw badRequest(refusal ?? 'This stop is not on the tour.');
|
||||
const saved =
|
||||
type === 'hike'
|
||||
? await this.saveHike(userId, tripId, placeId, request.expected_revision, hikePointsWithout(points, key))
|
||||
: await this.saveWalk(userId, tripId, placeId, request.expected_revision, { op: 'remove', key });
|
||||
return {
|
||||
tour: tourOf(saved, type),
|
||||
removed: {
|
||||
key,
|
||||
lat: gone.lat,
|
||||
lng: gone.lng,
|
||||
name: gone.saved.name ?? null,
|
||||
poi_category: gone.saved.poi_category ?? null,
|
||||
after_key: keyBefore(points, key),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A city walk names the station a stop comes after by its key; a station without one is not named yet. */
|
||||
private walkKeyOf(point: StopPoint): string {
|
||||
if (!point.saved.key)
|
||||
throw badRequest('The stations of this walk have no keys yet: open and save it in the planner.');
|
||||
return point.saved.key;
|
||||
}
|
||||
|
||||
/** A hike saved through new points, its way planned anew as update_tour plans it. */
|
||||
private async saveHike(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
placeId: number,
|
||||
expectedRevision: number,
|
||||
waypoints: HikeStop[] | null,
|
||||
): Promise<TourDetailResponse> {
|
||||
if (!waypoints) throw badRequest('The stop cannot go in there: name another point of the tour.');
|
||||
return this.hikes.update(userId, tripId, { placeId, expected_revision: expectedRevision, waypoints });
|
||||
}
|
||||
|
||||
/** A city walk saved through one change, as save_tour_change saves a confirmed proposal, then read as it is now. */
|
||||
private async saveWalk(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
placeId: number,
|
||||
expectedRevision: number,
|
||||
change: TourChange,
|
||||
): Promise<TourDetailResponse> {
|
||||
const changes = [change];
|
||||
const proposalId = proposalIdOf({ tripId, placeId, revision: expectedRevision, changes });
|
||||
await this.walkChanges.save(tripId, placeId, userId, {
|
||||
changes,
|
||||
expected_revision: expectedRevision,
|
||||
proposal_id: proposalId,
|
||||
confirmed: true,
|
||||
});
|
||||
return this.tours.getTour(String(tripId), String(placeId));
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,8 @@ import { TourPoiSearchController } from './tour-poi-search.controller';
|
||||
import { TourPoiSearchService } from './tour-poi-search.service';
|
||||
import { TourSkiController } from './tour-ski.controller';
|
||||
import { TourSkiService } from './tour-ski.service';
|
||||
import { TourStopsMcp } from './tour-stops.mcp';
|
||||
import { TourStopsService } from './tour-stops.service';
|
||||
import { TourTrailGradeController } from './tour-trail-grade.controller';
|
||||
import { TourTrailGradeService } from './tour-trail-grade.service';
|
||||
import { TourVisitsController } from './tour-visits.controller';
|
||||
@@ -215,6 +217,7 @@ import { Module } from '@nestjs/common';
|
||||
TourChangePreviewService,
|
||||
TourChangeSaveService,
|
||||
TourChangeUndoService,
|
||||
TourStopsService,
|
||||
TourDraftsService,
|
||||
TourActivitiesService,
|
||||
TourActivityStatsService,
|
||||
@@ -233,6 +236,8 @@ import { Module } from '@nestjs/common';
|
||||
TourActivityStatsMcp,
|
||||
CollectionToursMcp,
|
||||
TourPackingMcp,
|
||||
// P6: a found place taken into a saved hike or city walk, and out again.
|
||||
TourStopsMcp,
|
||||
],
|
||||
})
|
||||
export class ToursModule {}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Trip assistant e2e for P6 and P7, the whole app (buildApp, every global
|
||||
* guard, the Zod pipe, the response contracts, the real MCP registry with
|
||||
* its addon gates) with an instance connection to a fake OpenAI-compatible
|
||||
* server on loopback, scripted per case.
|
||||
*
|
||||
* Covered: a GPX file attached in the chat is read into its tracks without a
|
||||
* model turn, each held as a change card that takes it in as a hike, the GPX
|
||||
* text kept off the thread; applying one imports exactly that track through
|
||||
* import_tour_gpx and undoing it deletes the tour again; a file without a
|
||||
* track ends with no_tracks_found. A stop the model takes into a saved hike
|
||||
* with insert_tour_stop is held with its card, saved through update_tour's
|
||||
* write when applied (the planner stubbed on the container, as in
|
||||
* assistant-tours.e2e), its receipt offering the way back.
|
||||
*/
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
import { HikePlanService } from '../../src/nest/tours/hike-plan.service';
|
||||
import { makePlace } from '../helpers/factories/places';
|
||||
import { countRows, findRow } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { addTourWaypoints, makeTour } from '../helpers/factories/tours';
|
||||
import { makeTrip } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { resetRateLimits } from '../helpers/test-db';
|
||||
import { sessionCookie } from './harness';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import type { AssistantMessagePart, UiStreamChunk } from '@trek/shared';
|
||||
|
||||
import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// ASTE2E-TOURFILE-001 to ASTE2E-TOURFILE-004
|
||||
|
||||
// ── The fake provider ─────────────────────────────────────────────────────────
|
||||
|
||||
type Step = () => { chunks: object[] };
|
||||
|
||||
const provider = { requests: [] as object[], script: [] as Step[] };
|
||||
|
||||
const say =
|
||||
(text: string): Step =>
|
||||
() => ({
|
||||
chunks: [
|
||||
{ choices: [{ index: 0, delta: { content: text } }] },
|
||||
{ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
|
||||
{ choices: [], usage: { prompt_tokens: 12, completion_tokens: 4 } },
|
||||
],
|
||||
});
|
||||
|
||||
const callTool =
|
||||
(id: string, name: string, args: Record<string, unknown>): Step =>
|
||||
() => ({
|
||||
chunks: [
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, id, type: 'function', function: { name, arguments: JSON.stringify(args) } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] },
|
||||
],
|
||||
});
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<string> {
|
||||
let text = '';
|
||||
for await (const chunk of req) text += String(chunk);
|
||||
return text;
|
||||
}
|
||||
|
||||
async function answer(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
if (req.method === 'GET' && req.url?.startsWith('/api/tags')) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ models: [{ name: 'fake-model', size: 1 }] }));
|
||||
return;
|
||||
}
|
||||
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
provider.requests.push(JSON.parse(await readBody(req)) as object);
|
||||
const reply = (provider.script.shift() ?? say('Nothing more.'))();
|
||||
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
||||
for (const chunk of reply.chunks) res.write(`data: ${JSON.stringify(chunk)}\n\n`);
|
||||
res.end('data: [DONE]\n\n');
|
||||
}
|
||||
|
||||
// ── The app ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let orm: MikroORM;
|
||||
let fake: HttpServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
fake = createServer((req, res) => {
|
||||
answer(req, res).catch(() => res.destroy());
|
||||
});
|
||||
await new Promise<void>((resolve) => fake.listen(0, '127.0.0.1', resolve));
|
||||
nestApp = await buildApp();
|
||||
orm = nestApp.get(MikroORM);
|
||||
await setAddonEnabled(orm, 'ai_assistant', true);
|
||||
await setAddonEnabled(orm, 'tours', true);
|
||||
const admin = (await makeUser(orm, { role: 'admin' })).user.id;
|
||||
const { port } = fake.address() as AddressInfo;
|
||||
const res = await request(nestApp.getHttpServer())
|
||||
.put('/api/admin/ai/connection')
|
||||
.set('Cookie', sessionCookie(admin))
|
||||
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/v1`, model: 'fake-model' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetRateLimits(nestApp);
|
||||
provider.requests = [];
|
||||
provider.script = [];
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
await new Promise<void>((resolve) => fake.close(() => resolve()));
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const CTX = { locale: 'en', timeZone: 'Europe/Berlin' };
|
||||
|
||||
interface Answer {
|
||||
status: number;
|
||||
chunks: UiStreamChunk[];
|
||||
json: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function parseAnswer(status: number, type: string, text: string): Answer {
|
||||
if (!type.includes('text/event-stream')) {
|
||||
return { status, chunks: [], json: text ? (JSON.parse(text) as Record<string, unknown>) : null };
|
||||
}
|
||||
const events = text
|
||||
.split('\n\n')
|
||||
.filter((event) => event.startsWith('data: '))
|
||||
.map((event) => event.slice('data: '.length));
|
||||
expect(events.at(-1)).toBe('[DONE]');
|
||||
return { status, chunks: events.slice(0, -1).map((event) => JSON.parse(event) as UiStreamChunk), json: null };
|
||||
}
|
||||
|
||||
function asText(req: request.Test) {
|
||||
return req.buffer(true).parse((stream, done) => {
|
||||
let text = '';
|
||||
stream.setEncoding('utf8');
|
||||
stream.on('data', (chunk: string) => (text += chunk));
|
||||
stream.on('end', () => done(null, text));
|
||||
});
|
||||
}
|
||||
|
||||
async function post(path: string, userId: number, body: object): Promise<Answer> {
|
||||
const res = await asText(request(nestApp.getHttpServer()).post(path).set('Cookie', sessionCookie(userId)).send(body));
|
||||
return parseAnswer(res.status, String(res.headers['content-type']), res.body as string);
|
||||
}
|
||||
|
||||
async function upload(path: string, userId: number, fileName: string, text: string): Promise<Answer> {
|
||||
const req = request(nestApp.getHttpServer())
|
||||
.post(path)
|
||||
.set('Cookie', sessionCookie(userId))
|
||||
.field('locale', CTX.locale)
|
||||
.field('timeZone', CTX.timeZone)
|
||||
.attach('file', Buffer.from(text, 'utf8'), { filename: fileName, contentType: 'application/gpx+xml' });
|
||||
const res = await asText(req);
|
||||
return parseAnswer(res.status, String(res.headers['content-type']), res.body as string);
|
||||
}
|
||||
|
||||
const ofType = <T extends UiStreamChunk['type']>(chunks: UiStreamChunk[], type: T) =>
|
||||
chunks.filter((chunk): chunk is Extract<UiStreamChunk, { type: T }> => chunk.type === type);
|
||||
|
||||
async function traveller() {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { title: 'Bavaria' });
|
||||
return { userId: user.id, tripId: trip.id, base: `/api/trips/${trip.id}/assistant` };
|
||||
}
|
||||
|
||||
const WEEKEND = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk><name>Saturday</name><trkseg>
|
||||
<trkpt lat="47.5" lon="11.1"><ele>800</ele></trkpt>
|
||||
<trkpt lat="47.51" lon="11.1"><ele>1000</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
<trk><name>Sunday</name><trkseg>
|
||||
<trkpt lat="47.6" lon="11.2"><ele>700</ele></trkpt>
|
||||
<trkpt lat="47.61" lon="11.2"><ele>900</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`;
|
||||
|
||||
// ── Cases ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('a route file attached in the chat (P7)', () => {
|
||||
it('ASTE2E-TOURFILE-001: is read into its tracks without a model turn, each held as a hike, the GPX kept off the thread', async () => {
|
||||
const t = await traveller();
|
||||
const read = await upload(`${t.base}/attachments`, t.userId, 'weekend.gpx', WEEKEND);
|
||||
expect(read.status).toBe(200);
|
||||
expect(provider.requests).toHaveLength(0);
|
||||
const inputs = ofType(read.chunks, 'tool-input-available');
|
||||
expect(inputs.map((chunk) => chunk.toolName)).toEqual(['read_tour_file', 'import_tour_file', 'import_tour_file']);
|
||||
expect(JSON.stringify(read.chunks)).not.toContain('<trkpt');
|
||||
const changes = ofType(read.chunks, 'data-trek-change').map((chunk) => chunk.data);
|
||||
expect(changes.map((change) => [change.verbKey, change.subject, change.undoable])).toEqual([
|
||||
['importTour', 'Saturday', true],
|
||||
['importTour', 'Sunday', true],
|
||||
]);
|
||||
const threadId = ofType(read.chunks, 'data-trek-thread')[0]?.data.threadId;
|
||||
const shown = await request(nestApp.getHttpServer())
|
||||
.get(`${t.base}/threads/${threadId}`)
|
||||
.set('Cookie', sessionCookie(t.userId));
|
||||
expect(JSON.stringify(shown.body)).not.toContain('<trkpt');
|
||||
const parts = (shown.body.messages as Array<{ parts: AssistantMessagePart[] }>).flatMap((m) => m.parts);
|
||||
expect(parts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'tool-call',
|
||||
toolName: 'import_tour_file',
|
||||
args: { via: 'import_tour_gpx', file_name: 'weekend.gpx', tour_type: 'hike', track_name: 'Saturday' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ASTE2E-TOURFILE-002: applying one track imports exactly that track as a hike, and its undo deletes it again', async () => {
|
||||
const t = await traveller();
|
||||
const read = await upload(`${t.base}/attachments`, t.userId, 'weekend.gpx', WEEKEND);
|
||||
const threadId = ofType(read.chunks, 'data-trek-thread')[0]?.data.threadId;
|
||||
const [saturday, sunday] = ofType(read.chunks, 'data-trek-change').map((chunk) => chunk.data.toolCallId);
|
||||
provider.script = [say('Saturday is in the trip now.')];
|
||||
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
|
||||
...CTX,
|
||||
decisions: [
|
||||
{ toolCallId: saturday, approved: true },
|
||||
{ toolCallId: sunday, approved: false },
|
||||
],
|
||||
});
|
||||
expect(decided.status).toBe(200);
|
||||
const [applied] = ofType(decided.chunks, 'data-trek-applied');
|
||||
expect(applied?.data).toMatchObject({ toolCallId: saturday, entity: 'tour' });
|
||||
const placeId = applied?.data.ids[0] ?? 0;
|
||||
expect((await findRow(orm, Places, { id: placeId }))?.name).toBe('Saturday');
|
||||
expect(await countRows(orm, Places, { trip: t.tripId })).toBe(1);
|
||||
|
||||
const undone = await request(nestApp.getHttpServer())
|
||||
.post(`${t.base}/tool-calls/${saturday}/undo`)
|
||||
.set('Cookie', sessionCookie(t.userId))
|
||||
.send({});
|
||||
expect(undone.status).toBe(200);
|
||||
expect(await findRow(orm, Places, { id: placeId })).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTE2E-TOURFILE-003: a route file without a track ends with no_tracks_found and holds nothing', async () => {
|
||||
const t = await traveller();
|
||||
const pins = '<gpx version="1.1"><wpt lat="47" lon="11"><name>Pin</name></wpt></gpx>';
|
||||
const read = await upload(`${t.base}/attachments`, t.userId, 'pins.gpx', pins);
|
||||
expect(ofType(read.chunks, 'error')[0]).toMatchObject({ errorText: 'no_tracks_found' });
|
||||
expect(ofType(read.chunks, 'data-trek-change')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a stop the model takes into a saved hike (P6)', () => {
|
||||
it('ASTE2E-TOURFILE-004: is held with its card, saved when applied, and its receipt offers the way back', async () => {
|
||||
const t = await traveller();
|
||||
const place = await makePlace(orm, t.tripId, { name: 'Herzogstand', category: null });
|
||||
await makeTour(orm, place.id, { revision: 3 });
|
||||
await addTourWaypoints(orm, place.id, [
|
||||
{ lat: 47.5, lng: 11.1, station_key: 'start1', name: 'Car park' },
|
||||
{ lat: 47.505, lng: 11.1, station_key: 'alm111', name: 'Alm' },
|
||||
{ lat: 47.51, lng: 11.1, station_key: 'summit', name: 'Summit' },
|
||||
]);
|
||||
const hikes = nestApp.get(HikePlanService);
|
||||
const saved = (revision: number, keys: string[]) =>
|
||||
({
|
||||
tour: { place_id: place.id, name: 'Herzogstand', tour_type: 'hike', revision, has_waypoints: true },
|
||||
waypoints: keys.map((key, index) => ({
|
||||
key,
|
||||
lat: key === 'spring' ? 47.507 : [47.5, 47.505, 47.51][Math.min(index, 2)],
|
||||
lng: 11.1,
|
||||
role: 'via',
|
||||
sequence: index,
|
||||
})),
|
||||
route: { planned: true },
|
||||
replanned: true,
|
||||
}) as unknown as Awaited<ReturnType<HikePlanService['update']>>;
|
||||
const update = vi.spyOn(hikes, 'update').mockResolvedValue(saved(4, ['start1', 'alm111', 'spring', 'summit']));
|
||||
|
||||
provider.script = [
|
||||
callTool('call_tours', 'use_addon_tools', { groups: ['tours'] }),
|
||||
callTool('call_stop', 'insert_tour_stop', {
|
||||
placeId: place.id,
|
||||
expected_revision: 3,
|
||||
stop: { lat: 47.507, lng: 11.1, name: 'Spring' },
|
||||
}),
|
||||
say('Shall I add the spring?'),
|
||||
];
|
||||
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'Add the spring to the hike' });
|
||||
const threadId = ofType(turn.chunks, 'data-trek-thread')[0]?.data.threadId;
|
||||
const [change] = ofType(turn.chunks, 'data-trek-change');
|
||||
expect(change?.data).toMatchObject({ verbKey: 'addTourStop', subject: 'Herzogstand', undoable: true });
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
|
||||
provider.script = [say('Added.')];
|
||||
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
|
||||
...CTX,
|
||||
decisions: [{ toolCallId: change?.data.toolCallId, approved: true }],
|
||||
});
|
||||
expect(update.mock.calls[0]?.[2]).toMatchObject({ placeId: place.id, expected_revision: 3 });
|
||||
expect(update.mock.calls[0]?.[2].waypoints?.map((point) => point.name)).toEqual([
|
||||
'Car park',
|
||||
'Alm',
|
||||
'Spring',
|
||||
'Summit',
|
||||
]);
|
||||
const [applied] = ofType(decided.chunks, 'data-trek-applied');
|
||||
expect(applied?.data).toMatchObject({ entity: 'tour', ids: [place.id] });
|
||||
expect(applied?.data.undoUntil).toEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -382,6 +382,32 @@ export const toursRoadtrip: CaseGroup = {
|
||||
stub: routerDown,
|
||||
expect: ['tours:changed', 'place:updated'],
|
||||
},
|
||||
{
|
||||
// P6: a found place taken into the walk as a station, saved as save_tour_change saves one change.
|
||||
tool: 'insert_tour_stop',
|
||||
needs: ['tour'],
|
||||
setup: planWalk,
|
||||
args: (s) => ({
|
||||
tripId: s.trip.id,
|
||||
placeId: s.tourPlace.id,
|
||||
expected_revision: 1,
|
||||
stop: { lat: 48.106, lng: 11.5, name: 'Fountain' },
|
||||
after_key: 'walk-market',
|
||||
}),
|
||||
// The planner takes a found place in as a point of its draft and saves the walk through the same route write.
|
||||
rest: { method: 'put', path: tourPath, body: () => walkBody(1) },
|
||||
stub: routerDown,
|
||||
expect: ['tours:changed', 'place:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'remove_tour_stop',
|
||||
needs: ['tour'],
|
||||
setup: planWalk,
|
||||
args: (s) => ({ tripId: s.trip.id, placeId: s.tourPlace.id, expected_revision: 1, key: 'walk-market' }),
|
||||
rest: { method: 'put', path: tourPath, body: () => walkBody(1) },
|
||||
stub: routerDown,
|
||||
expect: ['tours:changed', 'place:updated'],
|
||||
},
|
||||
{
|
||||
tool: 'undo_tour_change',
|
||||
needs: ['tour'],
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* P6, P7: the server's own tour steps as the model is told of them, a found
|
||||
* place taken into a tour, a route file read and a track of it taken in,
|
||||
* through the note the history replays in place of a call.
|
||||
*/
|
||||
import { serverStepsNote } from '../../../../src/nest/assistant/assistant-history';
|
||||
import {
|
||||
ADD_FOUND_PLACE_TOOL,
|
||||
ASSISTANT_SERVER_TOOLS,
|
||||
IMPORT_TOUR_FILE_TOOL,
|
||||
READ_TOUR_FILE_TOOL,
|
||||
} from '../../../../src/nest/assistant/assistant-tools.constants';
|
||||
import type { AssistantMessagePart } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// AST-HISTORY-TOURS-001 to AST-HISTORY-TOURS-004
|
||||
|
||||
type Call = Extract<AssistantMessagePart, { type: 'tool-call' }>;
|
||||
|
||||
const call = (toolName: string, args: Record<string, unknown>, over: Partial<Call> = {}): Call => ({
|
||||
type: 'tool-call',
|
||||
toolCallId: 'c1',
|
||||
toolName,
|
||||
args,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('the tour steps the model is told of', () => {
|
||||
it('AST-HISTORY-TOURS-001: the route file steps are the server own, never replayed as calls', () => {
|
||||
expect(ASSISTANT_SERVER_TOOLS.has(READ_TOUR_FILE_TOOL)).toBe(true);
|
||||
expect(ASSISTANT_SERVER_TOOLS.has(IMPORT_TOUR_FILE_TOOL)).toBe(true);
|
||||
});
|
||||
|
||||
it('AST-HISTORY-TOURS-002: a found place taken into a tour, as it waits and once decided', () => {
|
||||
const args = { via: 'insert_tour_stop', placeId: 64, stop: { name: 'Spring' }, tourName: 'Herzogstand' };
|
||||
expect(serverStepsNote([call(ADD_FOUND_PLACE_TOOL, args, { approval: { id: 'c1' } })])).toBe(
|
||||
'The user picked "Spring" as a stop of the tour "Herzogstand" from the search results; it waits for them to add or discard it.',
|
||||
);
|
||||
expect(serverStepsNote([call(ADD_FOUND_PLACE_TOOL, args, { approval: { id: 'c1', approved: true } })])).toBe(
|
||||
'"Spring" as a stop of the tour "Herzogstand" was added.',
|
||||
);
|
||||
expect(serverStepsNote([call(ADD_FOUND_PLACE_TOOL, args, { approval: { id: 'c1', approved: false } })])).toBe(
|
||||
'The user discarded "Spring" as a stop of the tour "Herzogstand".',
|
||||
);
|
||||
});
|
||||
|
||||
it('AST-HISTORY-TOURS-003: a route file read, with its tracks, without any, or not at all', () => {
|
||||
const tracks = [{ name: 'Saturday', distance_km: 8.2, points: 120, has_heights: true }];
|
||||
expect(serverStepsNote([call(READ_TOUR_FILE_TOOL, { name: 'weekend.gpx' }, { result: { tracks } })])).toBe(
|
||||
`TREK read the attached route file "weekend.gpx" and found 1 track(s): ${JSON.stringify(tracks)}.`,
|
||||
);
|
||||
expect(serverStepsNote([call(READ_TOUR_FILE_TOOL, { name: 'pins.kml' }, { result: { tracks: [] } })])).toBe(
|
||||
'TREK read the attached route file "pins.kml" and found no track or route in it.',
|
||||
);
|
||||
expect(serverStepsNote([call(READ_TOUR_FILE_TOOL, { name: 'x.kmz' }, { isError: true })])).toBe(
|
||||
'TREK could not read the attached route file "x.kmz".',
|
||||
);
|
||||
});
|
||||
|
||||
it('AST-HISTORY-TOURS-004: a track taken in as a hike, or not', () => {
|
||||
const args = { via: 'import_tour_gpx', file_name: 'weekend.gpx', track_name: 'Saturday', tour_type: 'hike' };
|
||||
expect(serverStepsNote([call(IMPORT_TOUR_FILE_TOOL, args, { approval: { id: 'c1', approved: true } })])).toBe(
|
||||
'The track "Saturday" of "weekend.gpx" was taken in as a hike.',
|
||||
);
|
||||
expect(
|
||||
serverStepsNote([call(IMPORT_TOUR_FILE_TOOL, args, { approval: { id: 'c1', approved: true }, isError: true })]),
|
||||
).toBe('The track "Saturday" of "weekend.gpx" could not be taken in as a hike.');
|
||||
expect(
|
||||
serverStepsNote([call(IMPORT_TOUR_FILE_TOOL, args, { approval: { id: 'c1', resolution: 'cancelled' } })]),
|
||||
).toBe('The track "Saturday" of "weekend.gpx" was not taken in as a hike; the user moved on without deciding.');
|
||||
expect(serverStepsNote([call(IMPORT_TOUR_FILE_TOOL, args, { approval: { id: 'c1' } })])).toBe(
|
||||
'The track "Saturday" of "weekend.gpx" waits for the user to take it in as a hike or discard it.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* P7: a route file attached in the trip chat, as the assistant proposes it:
|
||||
* refused where tours are off, one held import per track (the GPX text on the
|
||||
* held call only), the tracks the trip has or no import takes named in the
|
||||
* read step and not proposed, at most a step's worth.
|
||||
*/
|
||||
import { AddonsService } from '../../../../src/nest/addons/addons.service';
|
||||
import {
|
||||
foundPlaceArgs,
|
||||
IMPORT_TOUR_FILE_TOOL,
|
||||
MAX_WRITES_PER_STEP,
|
||||
READ_TOUR_FILE_TOOL,
|
||||
writeToolOf,
|
||||
} from '../../../../src/nest/assistant/assistant-tools.constants';
|
||||
import { AssistantTourFileService } from '../../../../src/nest/assistant/assistant-tour-file.service';
|
||||
import { DomainError } from '../../../../src/nest/common/domain-error';
|
||||
import { TourFileReadService, type TourFileTrack } from '../../../../src/nest/tours/tour-file-read.service';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// AST-TOUR-FILE-001 to AST-TOUR-FILE-005
|
||||
|
||||
const track = (name: string, over: Partial<TourFileTrack> = {}): TourFileTrack => ({
|
||||
name,
|
||||
gpx: `<gpx><trk><name>${name}</name></trk></gpx>`,
|
||||
point_count: 120,
|
||||
distance_km: 8.24,
|
||||
has_heights: true,
|
||||
duplicate: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
function file(name: string): Express.Multer.File {
|
||||
return { originalname: name, buffer: Buffer.from('<gpx/>'), size: 6 } as Express.Multer.File;
|
||||
}
|
||||
|
||||
async function make(tracks: TourFileTrack[], opts: { tours?: boolean; whole?: boolean } = {}) {
|
||||
const read = vi.fn(async () => ({ tracks, whole: opts.whole === true }));
|
||||
const isAddonEnabled = vi.fn(async () => opts.tours !== false);
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
AssistantTourFileService,
|
||||
{ provide: TourFileReadService, useValue: { read } },
|
||||
{ provide: AddonsService, useValue: { isAddonEnabled } },
|
||||
],
|
||||
}).compile();
|
||||
return { service: moduleRef.get(AssistantTourFileService), read, isAddonEnabled };
|
||||
}
|
||||
|
||||
describe('AssistantTourFileService', () => {
|
||||
it('AST-TOUR-FILE-001: names its read step and the error of a file without a track', async () => {
|
||||
const { service } = await make([]);
|
||||
expect(service.readTool).toBe(READ_TOUR_FILE_TOOL);
|
||||
expect(service.emptyCode).toBe('no_tracks_found');
|
||||
});
|
||||
|
||||
it('AST-TOUR-FILE-002: refuses a route file where tours are off', async () => {
|
||||
const { service, isAddonEnabled } = await make([], { tours: false });
|
||||
await expect(service.ensureReadable(7, file('a.gpx'))).rejects.toBeInstanceOf(DomainError);
|
||||
expect(isAddonEnabled).toHaveBeenCalledWith('tours');
|
||||
await expect((await make([])).service.ensureReadable(7, file('a.gpx'))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('AST-TOUR-FILE-003: proposes one hike per track, the GPX text kept on the held call only', async () => {
|
||||
const { service, read } = await make([track('Saturday'), track('Sunday')]);
|
||||
const { found, proposals } = await service.propose(7, 5, file('weekend.gpx'));
|
||||
expect(read).toHaveBeenCalledWith(5, expect.any(Buffer), 'weekend.gpx');
|
||||
expect(proposals).toHaveLength(2);
|
||||
expect(proposals[0]).toEqual({
|
||||
toolName: IMPORT_TOUR_FILE_TOOL,
|
||||
args: {
|
||||
via: 'import_tour_gpx',
|
||||
file_name: 'weekend.gpx',
|
||||
tour_type: 'hike',
|
||||
track_name: 'Saturday',
|
||||
gpx: '<gpx><trk><name>Saturday</name></trk></gpx>',
|
||||
},
|
||||
shownArgs: { via: 'import_tour_gpx', file_name: 'weekend.gpx', tour_type: 'hike', track_name: 'Saturday' },
|
||||
});
|
||||
expect(found).toEqual({
|
||||
tracks: [
|
||||
{ name: 'Saturday', distance_km: 8.2, points: 120, has_heights: true },
|
||||
{ name: 'Sunday', distance_km: 8.2, points: 120, has_heights: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('AST-TOUR-FILE-004: names a track the trip has or no import takes, and proposes neither', async () => {
|
||||
const { service } = await make([track('Known', { duplicate: true }), track('Huge', { gpx: null }), track('New')]);
|
||||
const { found, proposals } = await service.propose(7, 5, file('mixed.kml'));
|
||||
expect(proposals.map((proposal) => proposal.args.track_name)).toEqual(['New']);
|
||||
expect(found.tracks).toEqual([
|
||||
expect.objectContaining({ name: 'Known', skipped: 'already_in_trip' }),
|
||||
expect.objectContaining({ name: 'Huge', skipped: 'too_large' }),
|
||||
expect.objectContaining({ name: 'New' }),
|
||||
]);
|
||||
expect(found.proposed).toBe(1);
|
||||
});
|
||||
|
||||
it('AST-TOUR-FILE-005: holds at most a step of tracks, runs each through import_tour_gpx with what it takes', async () => {
|
||||
const many = Array.from({ length: MAX_WRITES_PER_STEP + 3 }, (_, index) => track(`Leg ${index}`));
|
||||
const { service } = await make(many, { whole: true });
|
||||
const { found, proposals } = await service.propose(7, 5, file('legs.gpx'));
|
||||
expect(proposals).toHaveLength(MAX_WRITES_PER_STEP);
|
||||
expect(found.whole_file).toBe(true);
|
||||
const args = proposals[0]?.args ?? {};
|
||||
expect(writeToolOf(IMPORT_TOUR_FILE_TOOL, args)).toBe('import_tour_gpx');
|
||||
expect(foundPlaceArgs(args)).toEqual({ gpx: args.gpx, file_name: 'legs.gpx', tour_type: 'hike' });
|
||||
expect(writeToolOf(IMPORT_TOUR_FILE_TOOL, { ...args, via: 'delete_place' })).toBe(IMPORT_TOUR_FILE_TOOL);
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,10 @@ function make() {
|
||||
undo: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
};
|
||||
const foundPlaces = { add: vi.fn(async () => ({ message: { id: 'msg-3' } })) };
|
||||
const foundPlaces = {
|
||||
add: vi.fn(async () => ({ message: { id: 'msg-3' } })),
|
||||
insertIntoTour: vi.fn(async () => ({ message: { id: 'msg-4' } })),
|
||||
};
|
||||
const shares = { share: vi.fn(async () => ({ message: { id: 12, origin: 'assistant' } })) };
|
||||
const proposals = { take: vi.fn(async () => ({ message: { id: 'msg-4' } })) };
|
||||
const controller = new AssistantController(
|
||||
@@ -112,6 +115,11 @@ describe('AssistantController', () => {
|
||||
path: 'threads/:threadId/found-places',
|
||||
httpCode: 200,
|
||||
});
|
||||
expect(route('insertFoundPlace')).toEqual({
|
||||
method: RequestMethod.POST,
|
||||
path: 'threads/:threadId/found-places/tour',
|
||||
httpCode: 200,
|
||||
});
|
||||
expect(route('takeProposal')).toEqual({
|
||||
method: RequestMethod.POST,
|
||||
path: 'threads/:threadId/proposals',
|
||||
@@ -148,6 +156,9 @@ describe('AssistantController', () => {
|
||||
});
|
||||
expect(shares.share).toHaveBeenCalledWith(user, 5, 9, 'msg-3', 'Hi all', 'sock-1');
|
||||
expect(() => controller.addFoundPlace(user, '5', 'x', body)).toThrow(DomainError);
|
||||
const stop = { locale: 'en', timeZone: 'UTC', ref: 'call_1:0', tourId: 64 };
|
||||
expect(await controller.insertFoundPlace(user, '5', '9', stop)).toEqual({ message: { id: 'msg-4' } });
|
||||
expect(foundPlaces.insertIntoTour).toHaveBeenCalledWith(7, 5, 9, stop);
|
||||
});
|
||||
|
||||
it('ASTCTL-008: sharing asks for the trip chat right, "Plan this" and "+ Day" for editing places; nothing else asks', () => {
|
||||
@@ -155,6 +166,7 @@ describe('AssistantController', () => {
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handlers.share)).toBe('collab_edit');
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handlers.takeProposal)).toBe('place_edit');
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handlers.addFoundPlace)).toBe('place_edit');
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handlers.insertFoundPlace)).toBe('place_edit');
|
||||
for (const name of ['chat', 'decide', 'undo']) {
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handlers[name]), name).toBeUndefined();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* ASTCARD-TOUR-080 to ASTCARD-TOUR-087
|
||||
*
|
||||
* P6 in the chat: a found place taken into a saved tour as a stop
|
||||
* (insert_tour_stop), by the model or with "+ Tour" on a found place, and a
|
||||
* stop taken out (remove_tour_stop). The card names the tour, the stop and
|
||||
* the two points it goes between; each change goes back by the other while
|
||||
* the tour is still at the revision the change left it at.
|
||||
*/
|
||||
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
|
||||
import { stopArgs } from '../../../../../../src/nest/assistant/assistant-found-places.service';
|
||||
import {
|
||||
ADD_FOUND_PLACE_TOOL,
|
||||
foundPlaceArgs,
|
||||
writeToolOf,
|
||||
} from '../../../../../../src/nest/assistant/assistant-tools.constants';
|
||||
import { unchangedSinceApplied } from '../../../../../../src/nest/assistant/assistant-undo.helpers';
|
||||
import { loadFor } from '../../../../../../src/nest/assistant/cards/card-context';
|
||||
import type { PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
|
||||
import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
|
||||
import { ASSISTANT_UNDO_MAP, ASSISTANT_WRITE_TOOLS } from '../../../../../../src/nest/assistant/cards/registry';
|
||||
import { defaultAfterKeyOf } from '../../../../../../src/nest/assistant/cards/tour/tour-stop.cards';
|
||||
import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
|
||||
import { DomainError } from '../../../../../../src/nest/common/domain-error';
|
||||
import { assistantChangePreviewSchema, type AssistantFoundPlace } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
|
||||
vi.mock('../../../../../../src/nest/audit/audit-log.logger', () => logMock);
|
||||
|
||||
const card = buildTripCard(
|
||||
1,
|
||||
7,
|
||||
{ trip: { title: 'Alps' }, days: [] },
|
||||
{ places: [{ id: 64, name: 'Herzogstand', lat: 47.5, lng: 11.1 }] },
|
||||
null,
|
||||
);
|
||||
const ctx: PreviewContext = { card, categories: new Map(), locale: 'en-GB', today: '2026-06-01', airport: () => null };
|
||||
|
||||
/** get_tour_plan of the hike: three points and its line in brief. */
|
||||
const PLAN = {
|
||||
tour: { place_id: 64, name: 'Herzogstand', tour_type: 'hike', revision: 3 },
|
||||
waypoints: [
|
||||
{ key: 'start1', lat: 47.5, lng: 11.1, role: 'start', name: 'Car park' },
|
||||
{ key: 'alm111', lat: 47.505, lng: 11.1, role: 'via', name: 'Alm' },
|
||||
{ key: 'summit', lat: 47.51, lng: 11.1, role: 'end', name: 'Summit' },
|
||||
],
|
||||
line: {
|
||||
point_count: 3,
|
||||
has_heights: true,
|
||||
preview: [
|
||||
[47.5, 11.1, 800],
|
||||
[47.505, 11.1, 900],
|
||||
[47.51, 11.1, 1000],
|
||||
],
|
||||
},
|
||||
};
|
||||
const STOP = { lat: 47.507, lng: 11.1, name: 'Spring' };
|
||||
|
||||
function holding(value: unknown) {
|
||||
return {
|
||||
summary: { days: [] },
|
||||
places: { places: [] },
|
||||
reads: new Map([[readKey('get_tour_plan', { placeId: 64 }), value]]),
|
||||
};
|
||||
}
|
||||
|
||||
describe('a stop taken into a tour in the chat', () => {
|
||||
it('ASTCARD-TOUR-080: reads the tour, and its card names the tour, the stop and the two points it goes between', async () => {
|
||||
const read = vi.fn(async () => PLAN);
|
||||
const args = { placeId: 64, expected_revision: 3, stop: STOP };
|
||||
const loaded = await loadFor('insert_tour_stop', args, read);
|
||||
expect(read).toHaveBeenCalledWith('get_tour_plan', { placeId: 64 });
|
||||
const preview = describeToolCall('insert_tour_stop', 'tc-1', args, ctx, loaded);
|
||||
expect(assistantChangePreviewSchema.safeParse(preview).success).toBe(true);
|
||||
expect(preview).toMatchObject({ subject: 'Herzogstand', verbKey: 'addTourStop', placeIds: [64], undoable: true });
|
||||
expect(preview.rows).toEqual([
|
||||
{ op: 'add', labelKey: 'name', before: null, after: 'Spring' },
|
||||
{ op: 'add', labelKey: 'position', before: null, after: 'Alm → Summit' },
|
||||
]);
|
||||
expect(ASSISTANT_WRITE_TOOLS.insert_tour_stop).toMatchObject({ verbKey: 'addTourStop', entity: 'tour' });
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-081: a point named keeps its place in the card; a new end has nothing after it', () => {
|
||||
const named = describeToolCall(
|
||||
'insert_tour_stop',
|
||||
'tc-1',
|
||||
{ placeId: 64, stop: STOP, after_key: 'start1' },
|
||||
ctx,
|
||||
PLAN,
|
||||
);
|
||||
expect(named.rows[1]?.after).toBe('Car park → Alm');
|
||||
const end = describeToolCall(
|
||||
'insert_tour_stop',
|
||||
'tc-1',
|
||||
{ placeId: 64, stop: STOP, after_key: 'summit' },
|
||||
ctx,
|
||||
PLAN,
|
||||
);
|
||||
expect(end.rows[1]?.after).toBe('Summit →');
|
||||
// Without the tour read, the card still names the tour from the trip.
|
||||
expect(describeToolCall('insert_tour_stop', 'tc-1', { placeId: 64, stop: STOP }, ctx, null).subject).toBe(
|
||||
'Herzogstand',
|
||||
);
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-082: goes back by taking the stop out, while the tour is at the revision it left', () => {
|
||||
const undo = ASSISTANT_UNDO_MAP.insert_tour_stop;
|
||||
const result = { tour: { place_id: 64, revision: 4 }, stop: { key: 'spring', name: 'Spring' } };
|
||||
expect(undo.tool).toBe('remove_tour_stop');
|
||||
expect(undo.args({}, result, null)).toEqual({ placeId: 64, key: 'spring', expected_revision: 4 });
|
||||
expect(undo.args({}, { ...result, stop: { key: null } }, null)).toBeNull();
|
||||
const call = { args: { placeId: 64 }, preview: null };
|
||||
const at = (revision: number) => holding({ tour: { revision } });
|
||||
expect(unchangedSinceApplied('insert_tour_stop', result, at(4), call)).toBe(true);
|
||||
expect(unchangedSinceApplied('insert_tour_stop', result, at(5), call)).toBe(false);
|
||||
expect(unchangedSinceApplied('insert_tour_stop', result, at(4))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a stop taken out in the chat', () => {
|
||||
it('ASTCARD-TOUR-083: names the tour and the stop, and goes back by putting it after the point it followed', () => {
|
||||
const preview = describeToolCall('remove_tour_stop', 'tc-2', { placeId: 64, key: 'alm111' }, ctx, PLAN);
|
||||
expect(preview).toMatchObject({ subject: 'Herzogstand', verbKey: 'removeTourStop', undoable: true });
|
||||
expect(preview.rows).toEqual([{ op: 'add', labelKey: 'name', before: null, after: 'Alm' }]);
|
||||
const undo = ASSISTANT_UNDO_MAP.remove_tour_stop;
|
||||
const result = {
|
||||
tour: { place_id: 64, revision: 5 },
|
||||
removed: { key: 'alm111', lat: 47.505, lng: 11.1, name: 'Alm', poi_category: 'shelter', after_key: 'start1' },
|
||||
};
|
||||
expect(undo.tool).toBe('insert_tour_stop');
|
||||
expect(undo.args({}, result, null)).toEqual({
|
||||
placeId: 64,
|
||||
expected_revision: 5,
|
||||
stop: { lat: 47.505, lng: 11.1, name: 'Alm', poi_category: 'shelter' },
|
||||
after_key: 'start1',
|
||||
});
|
||||
expect(undo.args({}, { tour: result.tour, removed: { key: 'x' } }, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('"+ Tour" on a found place', () => {
|
||||
const place: AssistantFoundPlace = {
|
||||
ref: 'tc-9:0',
|
||||
name: 'Spring',
|
||||
address: null,
|
||||
lat: 47.507,
|
||||
lng: 11.1,
|
||||
kind: null,
|
||||
rating: null,
|
||||
openNow: null,
|
||||
googlePlaceId: null,
|
||||
osmId: null,
|
||||
photoKey: null,
|
||||
inTripPlaceId: null,
|
||||
};
|
||||
|
||||
it('ASTCARD-TOUR-084: holds insert_tour_stop after the last point before the place, against the revision read', () => {
|
||||
const args = stopArgs(place, 64, PLAN);
|
||||
expect(args).toEqual({
|
||||
via: 'insert_tour_stop',
|
||||
placeId: 64,
|
||||
expected_revision: 3,
|
||||
stop: { lat: 47.507, lng: 11.1, name: 'Spring' },
|
||||
after_key: 'alm111',
|
||||
tourName: 'Herzogstand',
|
||||
});
|
||||
expect(writeToolOf(ADD_FOUND_PLACE_TOOL, args)).toBe('insert_tour_stop');
|
||||
expect(foundPlaceArgs(args)).toEqual({
|
||||
placeId: 64,
|
||||
expected_revision: 3,
|
||||
stop: { lat: 47.507, lng: 11.1, name: 'Spring' },
|
||||
after_key: 'alm111',
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-085: refuses a tour of another trip, one not read and a type that takes no stop', () => {
|
||||
expect(() => stopArgs(place, 65, PLAN)).toThrow(DomainError);
|
||||
expect(() => stopArgs(place, 64, null)).toThrow(/Tour not found/);
|
||||
expect(() => stopArgs(place, 64, { ...PLAN, tour: { ...PLAN.tour, tour_type: 'bike' } })).toThrow(
|
||||
/hike or a city walk/,
|
||||
);
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-086: the card of a held "+ Tour" reads as the stop it adds', () => {
|
||||
const args = stopArgs(place, 64, PLAN);
|
||||
const preview = describeToolCall(ADD_FOUND_PLACE_TOOL, 'tc-3', args, ctx, PLAN);
|
||||
expect(preview).toMatchObject({ verbKey: 'addTourStop', subject: 'Herzogstand' });
|
||||
expect(preview.rows[1]?.after).toBe('Alm → Summit');
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-087: the point a place goes after skips a point the tour gave no key yet', () => {
|
||||
const unkeyed = { ...PLAN, waypoints: PLAN.waypoints.map((point) => ({ ...point, key: undefined })) };
|
||||
expect(defaultAfterKeyOf(PLAN, STOP)).toBe('alm111');
|
||||
expect(defaultAfterKeyOf(unkeyed, STOP)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* P7: a route file attached in the assistant's chat, read into one GPX text
|
||||
* per track (TourFileReadService over the Tours import's own reader, with the
|
||||
* real place import over a snapshot test db), and the cut itself
|
||||
* (tour-file-split.helpers): a GPX file keeps every point as it came, a KML
|
||||
* line is written as GPX.
|
||||
*/
|
||||
import { PlaceImportService } from '../../../src/nest/place-import/place-import.service';
|
||||
import { TourFileReadService } from '../../../src/nest/tours/tour-file-read.service';
|
||||
import { gpxOfLine, splitGpxTracks } from '../../../src/nest/tours/tour-file-split.helpers';
|
||||
import { TourFilesModule } from '../../../src/nest/tours/tour-files.module';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { TOUR_GPX_TEXT_MAX } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
// TOUR-FILE-READ-001 to TOUR-FILE-READ-008
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
let files: TourFileReadService;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [TourFilesModule] });
|
||||
files = t.get(TourFileReadService);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const TWO_TRACKS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata><name>Weekend</name></metadata>
|
||||
<wpt lat="47.0" lon="11.0"><name>Loose pin</name></wpt>
|
||||
<trk><name>Saturday</name><trkseg>
|
||||
<trkpt lat="47.5" lon="11.1"><ele>800</ele><time>2026-10-17T08:00:00Z</time></trkpt>
|
||||
<trkpt lat="47.51" lon="11.1"><ele>1000</ele><time>2026-10-17T09:00:00Z</time></trkpt>
|
||||
</trkseg></trk>
|
||||
<rte><name>Sunday</name>
|
||||
<rtept lat="47.6" lon="11.2"></rtept>
|
||||
<rtept lat="47.61" lon="11.2"></rtept>
|
||||
</rte>
|
||||
</gpx>`;
|
||||
|
||||
const KML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
||||
<Placemark><name>Ridge & lake</name><LineString><coordinates>11.1,47.5,800 11.1,47.51,1000</coordinates></LineString></Placemark>
|
||||
<Placemark><name>Pin</name><Point><coordinates>11.0,47.0</coordinates></Point></Placemark>
|
||||
</Document></kml>`;
|
||||
|
||||
describe('cutting a GPX file into its tracks', () => {
|
||||
it('TOUR-FILE-READ-001: one text per track and route, each with the head and without the loose points', () => {
|
||||
const pieces = splitGpxTracks(TWO_TRACKS);
|
||||
expect(pieces).toHaveLength(2);
|
||||
expect(pieces[0]).toContain('<metadata><name>Weekend</name></metadata>');
|
||||
expect(pieces[0]).toContain('<time>2026-10-17T08:00:00Z</time>');
|
||||
expect(pieces[0]).not.toContain('Sunday');
|
||||
expect(pieces[0]).not.toContain('Loose pin');
|
||||
expect(pieces[1]).toContain('<rte><name>Sunday</name>');
|
||||
expect(pieces.every((piece) => piece.trimEnd().endsWith('</gpx>'))).toBe(true);
|
||||
});
|
||||
|
||||
it('TOUR-FILE-READ-002: a <trkseg> is no track of its own, and a file without any gives nothing', () => {
|
||||
expect(splitGpxTracks('<gpx><trkseg></trkseg></gpx>')).toEqual([]);
|
||||
expect(splitGpxTracks('<gpx><trk><name>Open</name>')).toEqual([]);
|
||||
});
|
||||
|
||||
it('TOUR-FILE-READ-003: a KML line is written as one GPX track, its name escaped', () => {
|
||||
const gpx = gpxOfLine('Ridge & lake', 'Up <and> down', [
|
||||
[47.5, 11.1, 800],
|
||||
[47.51, 11.1],
|
||||
]);
|
||||
expect(gpx).toContain('<name>Ridge & lake</name>');
|
||||
expect(gpx).toContain('<desc>Up <and> down</desc>');
|
||||
expect(gpx).toContain('<trkpt lat="47.5" lon="11.1"><ele>800</ele></trkpt>');
|
||||
expect(gpx).toContain('<trkpt lat="47.51" lon="11.1"></trkpt>');
|
||||
const rows = t.get(PlaceImportService).readGpx(Buffer.from(gpx, 'utf8'), { importTracks: true });
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourFileReadService', () => {
|
||||
async function tripId() {
|
||||
const { user } = await makeUser(t.get(MikroORM));
|
||||
return (await makeTrip(t.get(MikroORM), user.id)).id;
|
||||
}
|
||||
|
||||
it('TOUR-FILE-READ-004: a GPX file comes back track by track, each one reading back as itself', async () => {
|
||||
const read = await files.read(await tripId(), Buffer.from(TWO_TRACKS, 'utf8'), 'weekend.gpx');
|
||||
expect(read.whole).toBe(false);
|
||||
expect(read.tracks.map((track) => [track.name, track.point_count, track.duplicate])).toEqual([
|
||||
['Saturday', 2, false],
|
||||
['Sunday', 2, false],
|
||||
]);
|
||||
expect(read.tracks[0]?.has_heights).toBe(true);
|
||||
expect(read.tracks[0]?.distance_km).toBeGreaterThan(1);
|
||||
expect(read.tracks[0]?.gpx).toContain('2026-10-17T08:00:00Z');
|
||||
});
|
||||
|
||||
it('TOUR-FILE-READ-005: a KML file gives its lines as GPX and counts the pins it leaves out', async () => {
|
||||
const read = await files.read(await tripId(), Buffer.from(KML, 'utf8'), 'ridge.kml');
|
||||
expect(read.tracks).toHaveLength(1);
|
||||
expect(read.tracks[0]?.name).toBe('Ridge & lake');
|
||||
expect(read.tracks[0]?.gpx).toContain('<trkseg>');
|
||||
expect(read.left_out).toBeDefined();
|
||||
});
|
||||
|
||||
it('TOUR-FILE-READ-006: a GPX file whose tracks cannot be cut comes back whole, as one import', async () => {
|
||||
const prefixed = TWO_TRACKS.replace(/<(\/?)(trk|rte)\b/g, '<$1gpx:$2').replace(
|
||||
'xmlns="http://www.topografix.com/GPX/1/1"',
|
||||
'xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpx="http://www.topografix.com/GPX/1/1"',
|
||||
);
|
||||
const read = await files.read(await tripId(), Buffer.from(prefixed, 'utf8'), 'weekend.gpx');
|
||||
if (read.tracks.length === 0) return; // a reader that takes no prefixed tags finds nothing to cut
|
||||
expect(read.whole).toBe(true);
|
||||
expect(read.tracks).toHaveLength(1);
|
||||
expect(read.tracks[0]?.name).toBe('weekend.gpx');
|
||||
});
|
||||
|
||||
it('TOUR-FILE-READ-007: a file without a line gives no track', async () => {
|
||||
const empty = '<gpx version="1.1"><wpt lat="47" lon="11"><name>Pin</name></wpt></gpx>';
|
||||
expect(await files.read(await tripId(), Buffer.from(empty, 'utf8'), 'pins.gpx')).toEqual({
|
||||
tracks: [],
|
||||
whole: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('TOUR-FILE-READ-008: a track longer than one import takes is named but carries no text', async () => {
|
||||
const points = Array.from(
|
||||
{ length: 20_000 },
|
||||
(_, i) =>
|
||||
`<trkpt lat="${47 + i / 100_000}" lon="11.1"><ele>800.123456</ele><time>2026-10-17T08:00:00Z</time></trkpt>`,
|
||||
).join('');
|
||||
const big = `<gpx version="1.1"><trk><name>Long</name><trkseg>${points}</trkseg></trk></gpx>`;
|
||||
expect(big.length).toBeGreaterThan(TOUR_GPX_TEXT_MAX);
|
||||
const read = await files.read(await tripId(), Buffer.from(big, 'utf8'), 'long.gpx');
|
||||
expect(read.tracks[0]).toMatchObject({ name: 'Long', gpx: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* P6: the pure half of a stop taken into a saved tour or out of it: where it
|
||||
* goes, what update_tour is handed for a hike, which stops may be taken out,
|
||||
* and the key the save gave the new stop.
|
||||
*/
|
||||
import {
|
||||
afterPointOf,
|
||||
hikePointsWithStop,
|
||||
hikePointsWithout,
|
||||
keyBefore,
|
||||
keyNear,
|
||||
removalRefusal,
|
||||
stopPointsOf,
|
||||
} from '../../../src/nest/tours/tour-stops.helpers';
|
||||
import type { GeometryPoint, TourWaypoint } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// TOUR-STOPS-HELPERS-001 to TOUR-STOPS-HELPERS-008
|
||||
|
||||
const wp = (key: string, lat: number, role: TourWaypoint['role'], over: Partial<TourWaypoint> = {}): TourWaypoint => ({
|
||||
key,
|
||||
lat,
|
||||
lng: 11.0,
|
||||
role,
|
||||
sequence: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
const START = wp('start1', 47.0, 'start', { name: 'Parking' });
|
||||
const HUT = wp('hut111', 47.01, 'via', { name: 'Hut', poi_category: 'shelter', stage_end: true });
|
||||
const END = wp('summit', 47.02, 'end', { name: 'Summit' });
|
||||
const LINE: GeometryPoint[] = [
|
||||
[47.0, 11.0],
|
||||
[47.01, 11.0],
|
||||
[47.02, 11.0],
|
||||
];
|
||||
|
||||
describe('where a stop goes', () => {
|
||||
it('TOUR-STOPS-HELPERS-001: after the last point before it along the line, or after the point named', () => {
|
||||
const points = stopPointsOf([START, HUT, END]);
|
||||
const between = afterPointOf(points, LINE, { lat: 47.015, lng: 11.0 }, undefined);
|
||||
expect(typeof between === 'string' ? between : between.id).toBe('hut111');
|
||||
const named = afterPointOf(points, LINE, { lat: 47.015, lng: 11.0 }, 'start1');
|
||||
expect(typeof named === 'string' ? named : named.id).toBe('start1');
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-HELPERS-002: refuses a key the tour does not have, a shaping point and a leg drawn by hand', () => {
|
||||
const free = {
|
||||
...HUT,
|
||||
fixed_leg: {
|
||||
kind: 'free' as const,
|
||||
geometry: [[47.01, 11.0] as [number, number], [47.02, 11.0] as [number, number]],
|
||||
},
|
||||
};
|
||||
const shaping = wp('shape1', 47.005, 'via', { shaping: true });
|
||||
expect(afterPointOf(stopPointsOf([START, HUT, END]), LINE, START, 'nope00')).toMatch(
|
||||
/no point with the key nope00/,
|
||||
);
|
||||
expect(afterPointOf(stopPointsOf([START, shaping, END]), LINE, START, 'shape1')).toMatch(/only shapes the line/);
|
||||
expect(afterPointOf(stopPointsOf([START, free, END]), LINE, START, 'hut111')).toMatch(/drawn by hand/);
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-HELPERS-003: a loop takes nothing after its way back', () => {
|
||||
const back = wp('back11', 47.0, 'end');
|
||||
expect(afterPointOf(stopPointsOf([START, HUT, back]), LINE, HUT, 'back11')).toMatch(/way back of a loop/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a hike handed to update_tour', () => {
|
||||
it('TOUR-STOPS-HELPERS-004: keeps names, kinds and stage ends, the stop in its place', () => {
|
||||
const points = stopPointsOf([START, HUT, END]);
|
||||
const next = hikePointsWithStop(points, 'hut111', {
|
||||
lat: 47.015,
|
||||
lng: 11.0,
|
||||
name: 'Spring',
|
||||
poi_category: 'drinking_water',
|
||||
});
|
||||
expect(next).toEqual([
|
||||
{ lat: 47.0, lng: 11.0, name: 'Parking' },
|
||||
{ lat: 47.01, lng: 11.0, name: 'Hut', poi_category: 'shelter', stage_end: true },
|
||||
{ lat: 47.015, lng: 11.0, name: 'Spring', poi_category: 'drinking_water' },
|
||||
{ lat: 47.02, lng: 11.0, name: 'Summit' },
|
||||
]);
|
||||
expect(hikePointsWithStop(points, 'nope00', { lat: 47.015, lng: 11.0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-HELPERS-005: takes the stop out and leaves the rest as it was', () => {
|
||||
expect(hikePointsWithout(stopPointsOf([START, HUT, END]), 'hut111')).toEqual([
|
||||
{ lat: 47.0, lng: 11.0, name: 'Parking' },
|
||||
{ lat: 47.02, lng: 11.0, name: 'Summit' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('which stop may be taken out', () => {
|
||||
it('TOUR-STOPS-HELPERS-006: never the start, the way back of a loop, or a point of a tour of two', () => {
|
||||
const back = wp('back11', 47.0, 'end');
|
||||
expect(removalRefusal(stopPointsOf([START, HUT, END]), 'hut111')).toBeNull();
|
||||
expect(removalRefusal(stopPointsOf([START, HUT, END]), 'summit')).toBeNull();
|
||||
expect(removalRefusal(stopPointsOf([START, HUT, END]), 'start1')).toMatch(/start/);
|
||||
expect(removalRefusal(stopPointsOf([START, END]), 'summit')).toMatch(/at least its start and its end/);
|
||||
expect(removalRefusal(stopPointsOf([START, HUT, back]), 'back11')).toMatch(/way back of a loop/);
|
||||
expect(removalRefusal(stopPointsOf([START, HUT, END]), 'nope00')).toMatch(/no point with the key/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the keys around a stop', () => {
|
||||
it('TOUR-STOPS-HELPERS-007: the key the save gave the stop is the nearest keyed point within a few metres', () => {
|
||||
expect(keyNear([START, HUT, END], { lat: 47.01, lng: 11.0001 })).toBe('hut111');
|
||||
expect(keyNear([START, HUT, END], { lat: 47.03, lng: 11.0 })).toBeNull();
|
||||
expect(keyNear([{ ...HUT, key: undefined }], { lat: 47.01, lng: 11.0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-HELPERS-008: the point a stop followed, by its key; none before the start', () => {
|
||||
const points = stopPointsOf([START, HUT, END]);
|
||||
expect(keyBefore(points, 'hut111')).toBe('start1');
|
||||
expect(keyBefore(points, 'start1')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* P6: TourStopsMcp over a snapshot test db: a found place taken into a saved
|
||||
* hike as a stop with insert_tour_stop and taken out again with
|
||||
* remove_tour_stop, through the write update_tour saves with. The hike is
|
||||
* created through create_hike; its way is planned by a stub of
|
||||
* HikeRoutingService, as in the hike suite. The gates are the tours': trip
|
||||
* access, then place_edit, on the Tours addon.
|
||||
*/
|
||||
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../../src/db/entities/Tours.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
||||
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
|
||||
import { HikePlanMcp } from '../../../src/nest/tours/hike-plan.mcp';
|
||||
import { HikeRoutingService, type PlannedHike } from '../../../src/nest/tours/hike-routing.service';
|
||||
import { TourPoiSearchService } from '../../../src/nest/tours/tour-poi-search.service';
|
||||
import { TourStopsMcp } from '../../../src/nest/tours/tour-stops.mcp';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { WeatherService } from '../../../src/nest/weather/weather.service';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { countRows, findRow } from '../../helpers/factories/rows';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import type { GeometryPoint, HikePlanRequest } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// TOUR-STOPS-MCP-001 to TOUR-STOPS-MCP-005
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const plan = vi.fn<HikeRoutingService['plan']>();
|
||||
let t: TestModule;
|
||||
let stops: TourStopsMcp;
|
||||
let hikes: HikePlanMcp;
|
||||
const orm = () => t.get(MikroORM);
|
||||
|
||||
const LINE: GeometryPoint[] = [
|
||||
[47.5, 11.1, 800],
|
||||
[47.505, 11.1, 900],
|
||||
[47.51, 11.1, 1000],
|
||||
];
|
||||
|
||||
/** The stub's plan: the way above under the limit asked for, whatever the points. */
|
||||
function routed(request: HikePlanRequest): PlannedHike {
|
||||
return {
|
||||
plan: {
|
||||
kind: 'routed',
|
||||
max_hiking_difficulty: request.max_hiking_difficulty ?? 2,
|
||||
hiking_preferences: request.hiking_preferences ?? null,
|
||||
distance_m: 1112,
|
||||
ascent_m: 200,
|
||||
descent_m: 0,
|
||||
duration_minutes: 52,
|
||||
duration_basis: 'din33466',
|
||||
has_heights: true,
|
||||
line: LINE,
|
||||
profile: [
|
||||
[0, 800],
|
||||
[1112, 1000],
|
||||
],
|
||||
grades: null,
|
||||
},
|
||||
line: LINE,
|
||||
snapped: request.waypoints.map(() => 5),
|
||||
legs: request.waypoints.slice(1).map(() => 300),
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [ToursModule],
|
||||
overrides: [
|
||||
{ provide: HikeRoutingService, useValue: { plan } },
|
||||
{ provide: TourPoiSearchService, useValue: { search: vi.fn() } },
|
||||
{ provide: WeatherService, useValue: { getDetailed: vi.fn() } },
|
||||
],
|
||||
});
|
||||
stops = t.get(TourStopsMcp);
|
||||
hikes = t.get(HikePlanMcp);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
plan.mockReset().mockImplementation(async (_userId, _tripId, request) => routed(request));
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const ctx = (userId: number) => ({ userId }) as McpContext;
|
||||
|
||||
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
|
||||
const text = result.content[0]?.text ?? '';
|
||||
return result.isError ? { error: text } : JSON.parse(text);
|
||||
}
|
||||
|
||||
const WAYPOINTS = [
|
||||
{ lat: 47.5, lng: 11.1, name: 'Car park' },
|
||||
{ lat: 47.505, lng: 11.1, name: 'Alm' },
|
||||
{ lat: 47.51, lng: 11.1, name: 'Summit' },
|
||||
];
|
||||
const SPRING = { lat: 47.507, lng: 11.1, name: 'Spring', poi_category: 'drinking_water' as const };
|
||||
|
||||
async function fixture() {
|
||||
const { user } = await makeUser(orm());
|
||||
const { user: outsider } = await makeUser(orm());
|
||||
const trip = await makeTrip(orm(), user.id);
|
||||
const hike = read(
|
||||
await hikes.createHike({ tripId: trip.id, name: 'Herzogstand', waypoints: WAYPOINTS }, ctx(user.id)),
|
||||
);
|
||||
return { user, outsider, trip, placeId: hike.tour.place_id as number };
|
||||
}
|
||||
|
||||
describe('TourStopsMcp', () => {
|
||||
it('TOUR-STOPS-MCP-001: both tools write on the Tours addon, in the trips group', async () => {
|
||||
const options = (method: string) => {
|
||||
const entry = getEntry(TourStopsMcp as unknown as ClassRef, method);
|
||||
if (!entry || entry.kind !== 'tool') throw new Error(`${method} is no MCP tool`);
|
||||
return entry.options;
|
||||
};
|
||||
const tools = ['insertTourStop', 'removeTourStop'].map(options);
|
||||
expect(tools.map((tool) => [tool.name, tool.annotations?.readOnlyHint, tool.access])).toEqual([
|
||||
['insert_tour_stop', false, { group: 'trips', mode: 'write' }],
|
||||
['remove_tour_stop', false, { group: 'trips', mode: 'write' }],
|
||||
]);
|
||||
expect(tools[0].description).toContain('after_key');
|
||||
const isAddonEnabled = vi.fn(async (id: string) => id === 'tours');
|
||||
const gated = await Promise.all(tools.map((tool) => tool.when?.(ctx(1), { addons: { isAddonEnabled } })));
|
||||
expect(gated).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-MCP-002: takes a place in after the last point before it, plans the hike anew and raises its revision', async () => {
|
||||
const f = await fixture();
|
||||
plan.mockClear();
|
||||
const answer = read(
|
||||
await stops.insertTourStop(
|
||||
{ tripId: f.trip.id, placeId: f.placeId, expected_revision: 1, stop: SPRING },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
);
|
||||
expect(plan).toHaveBeenCalledTimes(1);
|
||||
expect(plan.mock.calls[0]?.[2].waypoints.map((point) => point.lat)).toEqual([47.5, 47.505, 47.507, 47.51]);
|
||||
expect(answer.tour).toMatchObject({ place_id: f.placeId, tour_type: 'hike', revision: 2 });
|
||||
expect(answer.stop).toMatchObject({ name: 'Spring', after_name: 'Alm' });
|
||||
expect(typeof answer.stop.key).toBe('string');
|
||||
expect(await countRows(orm(), TourWaypoints, { place: f.placeId })).toBe(4);
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-MCP-003: takes the stop out again by its key, back to the points it had', async () => {
|
||||
const f = await fixture();
|
||||
const own = ctx(f.user.id);
|
||||
const inserted = read(
|
||||
await stops.insertTourStop({ tripId: f.trip.id, placeId: f.placeId, expected_revision: 1, stop: SPRING }, own),
|
||||
);
|
||||
const removed = read(
|
||||
await stops.removeTourStop(
|
||||
{ tripId: f.trip.id, placeId: f.placeId, expected_revision: 2, key: inserted.stop.key },
|
||||
own,
|
||||
),
|
||||
);
|
||||
expect(removed.tour.revision).toBe(3);
|
||||
expect(removed.removed).toMatchObject({ key: inserted.stop.key, name: 'Spring', lat: 47.507 });
|
||||
expect(await countRows(orm(), TourWaypoints, { place: f.placeId })).toBe(3);
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-MCP-004: a stale revision is refused and the hike stays as it was', async () => {
|
||||
const f = await fixture();
|
||||
await expect(
|
||||
stops.insertTourStop(
|
||||
{ tripId: f.trip.id, placeId: f.placeId, expected_revision: 4, stop: SPRING },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
).rejects.toThrow('This tour was changed in the meantime');
|
||||
expect((await findRow(orm(), Tours, { place: f.placeId }))?.revision).toBe(1);
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-MCP-005: an outsider is refused, and a member without place_edit, as REST refuses them', async () => {
|
||||
const f = await fixture();
|
||||
const request = { tripId: f.trip.id, placeId: f.placeId, expected_revision: 1, stop: SPRING };
|
||||
expect(read(await stops.insertTourStop(request, ctx(f.outsider.id)))).toEqual({
|
||||
error: 'Trip not found or access denied.',
|
||||
});
|
||||
const denied = vi.spyOn(t.get(McpToolGuardsService), 'hasTripPermission').mockResolvedValue(false);
|
||||
expect((await stops.insertTourStop(request, ctx(f.user.id))).isError).toBe(true);
|
||||
const removal = { tripId: f.trip.id, placeId: f.placeId, expected_revision: 1, key: 'abcdef' };
|
||||
expect((await stops.removeTourStop(removal, ctx(f.user.id))).isError).toBe(true);
|
||||
expect(denied).toHaveBeenCalledWith('place_edit', f.trip.id, f.user.id);
|
||||
denied.mockRestore();
|
||||
expect(await countRows(orm(), TourWaypoints, { place: f.placeId })).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* P6: TourStopsService takes a stop into a saved hike or city walk, and out
|
||||
* again, through the paths update_tour and save_tour_change save with. Those
|
||||
* are stubs here (their own suites run them against a db): this suite pins
|
||||
* what they are handed, the refusals before them and the answer.
|
||||
*/
|
||||
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||
import { HikePlanService } from '../../../src/nest/tours/hike-plan.service';
|
||||
import { proposalIdOf } from '../../../src/nest/tours/tour-change-proposal';
|
||||
import { TourChangeSaveService } from '../../../src/nest/tours/tour-change-save.service';
|
||||
import { TourStopsService } from '../../../src/nest/tours/tour-stops.service';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TourDetailResponse, TourWaypoint } from '@trek/shared';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// TOUR-STOPS-SVC-001 to TOUR-STOPS-SVC-009
|
||||
|
||||
const wp = (key: string, lat: number, role: TourWaypoint['role'], name: string): TourWaypoint => ({
|
||||
key,
|
||||
lat,
|
||||
lng: 11.0,
|
||||
role,
|
||||
sequence: 0,
|
||||
name,
|
||||
});
|
||||
const START = wp('start1', 47.0, 'start', 'Parking');
|
||||
const HUT = wp('hut111', 47.01, 'via', 'Hut');
|
||||
const END = wp('summit', 47.02, 'end', 'Summit');
|
||||
const SPRING = wp('spring', 47.015, 'via', 'Spring');
|
||||
|
||||
function detail(tourType: string, waypoints: TourWaypoint[], revision = 3, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
tour: {
|
||||
place_id: 64,
|
||||
name: 'Herzogstand',
|
||||
tour_type: tourType,
|
||||
revision,
|
||||
distance: 8.2,
|
||||
duration: 190,
|
||||
has_waypoints: true,
|
||||
...extra,
|
||||
},
|
||||
waypoints,
|
||||
} as unknown as TourDetailResponse;
|
||||
}
|
||||
|
||||
const getTour = vi.fn<(tripId: string, placeId: string) => Promise<TourDetailResponse>>();
|
||||
const savedLine = vi.fn(async () => [
|
||||
[47.0, 11.0],
|
||||
[47.01, 11.0],
|
||||
[47.02, 11.0],
|
||||
]);
|
||||
const update = vi.fn();
|
||||
const save = vi.fn();
|
||||
let service: TourStopsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
getTour.mockReset();
|
||||
update.mockReset().mockResolvedValue({ ...detail('hike', [START, HUT, SPRING, END], 4), route: { planned: true } });
|
||||
save.mockReset().mockResolvedValue({ saved: true });
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
TourStopsService,
|
||||
{ provide: ToursService, useValue: { getTour, savedLine } },
|
||||
{ provide: HikePlanService, useValue: { update } },
|
||||
{ provide: TourChangeSaveService, useValue: { save } },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(TourStopsService);
|
||||
});
|
||||
|
||||
const stop = { lat: 47.015, lng: 11.0, name: 'Spring', poi_category: 'drinking_water' as const };
|
||||
|
||||
describe('a stop taken into a hike', () => {
|
||||
it('TOUR-STOPS-SVC-001: goes after the last point before it, and the hike is planned through the new points', async () => {
|
||||
getTour.mockResolvedValue(detail('hike', [START, HUT, END]));
|
||||
const answer = await service.insert(7, 5, { placeId: 64, expected_revision: 3, stop });
|
||||
expect(update).toHaveBeenCalledWith(7, 5, {
|
||||
placeId: 64,
|
||||
expected_revision: 3,
|
||||
waypoints: [
|
||||
{ lat: 47.0, lng: 11.0, name: 'Parking' },
|
||||
{ lat: 47.01, lng: 11.0, name: 'Hut' },
|
||||
{ lat: 47.015, lng: 11.0, name: 'Spring', poi_category: 'drinking_water' },
|
||||
{ lat: 47.02, lng: 11.0, name: 'Summit' },
|
||||
],
|
||||
});
|
||||
expect(answer.tour).toMatchObject({ place_id: 64, tour_type: 'hike', revision: 4 });
|
||||
expect(answer.stop).toMatchObject({ key: 'spring', after_key: 'hut111', after_name: 'Hut' });
|
||||
expect(answer.before).toEqual({ distance_km: 8.2, minutes: 190 });
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-SVC-002: is refused against a revision the hike moved past, before anything is planned', async () => {
|
||||
getTour.mockResolvedValue(detail('hike', [START, HUT, END], 5));
|
||||
await expect(service.insert(7, 5, { placeId: 64, expected_revision: 3, stop })).rejects.toThrow(
|
||||
/changed in the meantime/,
|
||||
);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-SVC-003: refuses another tour type and a recorded track without points of its own', async () => {
|
||||
getTour.mockResolvedValue(detail('bike', [START, END]));
|
||||
await expect(service.insert(7, 5, { placeId: 64, expected_revision: 3, stop })).rejects.toThrow(
|
||||
/hike or a city walk/,
|
||||
);
|
||||
getTour.mockResolvedValue(detail('hike', [START, END], 3, { has_waypoints: false }));
|
||||
await expect(service.insert(7, 5, { placeId: 64, expected_revision: 3, stop })).rejects.toThrow(/recorded track/);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-SVC-004: names the reason when the point named takes no stop', async () => {
|
||||
getTour.mockResolvedValue(detail('hike', [START, HUT, END]));
|
||||
const refused = service.insert(7, 5, { placeId: 64, expected_revision: 3, stop, after_key: 'nope00' });
|
||||
await expect(refused).rejects.toBeInstanceOf(DomainError);
|
||||
await expect(refused).rejects.toThrow(/no point with the key/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a stop taken into a city walk', () => {
|
||||
it('TOUR-STOPS-SVC-005: is saved as one add after the station, with the proposal id a save checks', async () => {
|
||||
getTour
|
||||
.mockResolvedValueOnce(detail('walk', [START, HUT, END]))
|
||||
.mockResolvedValueOnce(detail('walk', [START, HUT, SPRING, END], 4));
|
||||
const answer = await service.insert(7, 5, {
|
||||
placeId: 64,
|
||||
expected_revision: 3,
|
||||
stop: { lat: 47.015, lng: 11.0, name: 'Spring', opening_hours: 'Mo-Su 09:00-18:00' },
|
||||
after_key: 'hut111',
|
||||
});
|
||||
const changes = [
|
||||
{
|
||||
op: 'add',
|
||||
after_key: 'hut111',
|
||||
station: { lat: 47.015, lng: 11.0, name: 'Spring', opening_hours: 'Mo-Su 09:00-18:00' },
|
||||
},
|
||||
];
|
||||
expect(save).toHaveBeenCalledWith(5, 64, 7, {
|
||||
changes,
|
||||
expected_revision: 3,
|
||||
proposal_id: proposalIdOf({ tripId: 5, placeId: 64, revision: 3, changes: changes as never }),
|
||||
confirmed: true,
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(answer.tour).toMatchObject({ tour_type: 'walk', revision: 4 });
|
||||
expect(answer.stop.key).toBe('spring');
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-SVC-006: refuses a walk whose stations have no keys yet', async () => {
|
||||
getTour.mockResolvedValue(detail('walk', [START, { ...HUT, key: undefined }, END]));
|
||||
const refused = service.insert(7, 5, { placeId: 64, expected_revision: 3, stop });
|
||||
await expect(refused).rejects.toThrow(/no keys yet/);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a stop taken out', () => {
|
||||
it('TOUR-STOPS-SVC-007: plans a hike through the points left and names what it took out', async () => {
|
||||
getTour.mockResolvedValue(detail('hike', [START, HUT, SPRING, END], 4));
|
||||
update.mockResolvedValue({ ...detail('hike', [START, HUT, END], 5), route: { planned: true } });
|
||||
const answer = await service.remove(7, 5, { placeId: 64, expected_revision: 4, key: 'spring' });
|
||||
expect(update.mock.calls[0]?.[2]).toMatchObject({ placeId: 64, expected_revision: 4 });
|
||||
expect(update.mock.calls[0]?.[2].waypoints).toHaveLength(3);
|
||||
expect(answer.removed).toEqual({
|
||||
key: 'spring',
|
||||
lat: 47.015,
|
||||
lng: 11.0,
|
||||
name: 'Spring',
|
||||
poi_category: null,
|
||||
after_key: 'hut111',
|
||||
});
|
||||
expect(answer.tour.revision).toBe(5);
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-SVC-008: removes a station of a city walk through the save of one change', async () => {
|
||||
getTour
|
||||
.mockResolvedValueOnce(detail('walk', [START, HUT, SPRING, END], 4))
|
||||
.mockResolvedValueOnce(detail('walk', [START, HUT, END], 5));
|
||||
await service.remove(7, 5, { placeId: 64, expected_revision: 4, key: 'spring' });
|
||||
expect(save.mock.calls[0]?.[3]).toMatchObject({ changes: [{ op: 'remove', key: 'spring' }], expected_revision: 4 });
|
||||
});
|
||||
|
||||
it('TOUR-STOPS-SVC-009: keeps the start of the tour', async () => {
|
||||
getTour.mockResolvedValue(detail('hike', [START, HUT, END]));
|
||||
await expect(service.remove(7, 5, { placeId: 64, expected_revision: 3, key: 'start1' })).rejects.toThrow(/start/);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
assistantChatRequestSchema,
|
||||
assistantDecisionsRequestSchema,
|
||||
assistantFoundPlaceSchema,
|
||||
assistantInsertFoundPlaceRequestSchema,
|
||||
assistantMessageSchema,
|
||||
assistantPlacesSchema,
|
||||
assistantStopRequestSchema,
|
||||
assistantWeatherSchema,
|
||||
isAssistantTourFile,
|
||||
UI_STREAM_DONE,
|
||||
uiStreamChunkSchema,
|
||||
type UiStreamChunk,
|
||||
@@ -263,6 +265,32 @@ describe('assistantAddFoundPlaceRequestSchema', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistantInsertFoundPlaceRequestSchema', () => {
|
||||
const parse = (body: Record<string, unknown>) =>
|
||||
assistantInsertFoundPlaceRequestSchema.safeParse({ ...ctx, ...body });
|
||||
|
||||
it('names a found place by its ref and the saved tour it goes into', () => {
|
||||
expect(parse({ ref: 'toolu_01:2', tourId: 64 }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a bad tour, a day instead of a tour and anything about the stop itself', () => {
|
||||
expect(parse({ ref: 'a:1', tourId: 0 }).success).toBe(false);
|
||||
expect(parse({ ref: 'a:1', dayId: 3 }).success).toBe(false);
|
||||
expect(parse({ ref: 'a:1', tourId: 64, after_key: 'abc123' }).success).toBe(false);
|
||||
expect(assistantInsertFoundPlaceRequestSchema.safeParse({ ref: 'a:1', tourId: 64 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('route files attached in the chat', () => {
|
||||
it('tells a GPX, KML or KMZ file by its name, whatever its case', () => {
|
||||
expect(isAssistantTourFile('Herzogstand.GPX')).toBe(true);
|
||||
expect(isAssistantTourFile('map.kml')).toBe(true);
|
||||
expect(isAssistantTourFile('map.kmz')).toBe(true);
|
||||
expect(isAssistantTourFile('ticket.pdf')).toBe(false);
|
||||
expect(isAssistantTourFile('gpx')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the effect of a change', () => {
|
||||
const preview = {
|
||||
toolCallId: 'c1',
|
||||
|
||||
@@ -68,6 +68,8 @@ export const ASSISTANT_ERROR_CODES = [
|
||||
'empty_answer',
|
||||
'offline',
|
||||
'no_bookings_found',
|
||||
// An attached GPX, KML or KMZ file without a single track or route to take in as a tour.
|
||||
'no_tracks_found',
|
||||
'import_unavailable',
|
||||
// A found place was suggested while the thread still holds changes nobody decided on.
|
||||
'decide_first',
|
||||
@@ -291,6 +293,30 @@ export const assistantAddFoundPlaceRequestSchema = assistantClientCtxSchema
|
||||
.strict();
|
||||
export type AssistantAddFoundPlaceRequest = z.infer<typeof assistantAddFoundPlaceRequestSchema>;
|
||||
|
||||
/**
|
||||
* POST /threads/:threadId/found-places/tour: "+ Tour" on a place a search
|
||||
* found, while a saved tour is open in the planner. Held like "+ Day 3": the
|
||||
* place comes from the stored search results, the tour must be one of this
|
||||
* trip's, and the server picks where along it the stop goes.
|
||||
*/
|
||||
export const assistantInsertFoundPlaceRequestSchema = assistantClientCtxSchema
|
||||
.extend({
|
||||
ref: z.string().min(1).max(80),
|
||||
tourId: idSchema,
|
||||
})
|
||||
.strict();
|
||||
export type AssistantInsertFoundPlaceRequest = z.infer<typeof assistantInsertFoundPlaceRequestSchema>;
|
||||
|
||||
/** The route files the chat takes in as tours (P7), beside the booking files. */
|
||||
export const ASSISTANT_TOUR_FILE_EXTENSIONS = ['.gpx', '.kml', '.kmz'] as const;
|
||||
|
||||
/** Whether a file the user attached is a route file, by its name. */
|
||||
export function isAssistantTourFile(fileName: string): boolean {
|
||||
const dot = fileName.lastIndexOf('.');
|
||||
const ext = dot < 0 ? '' : fileName.slice(dot).toLowerCase();
|
||||
return (ASSISTANT_TOUR_FILE_EXTENSIONS as readonly string[]).includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /threads/:threadId/proposals: take a proposal an answer showed as a
|
||||
* card ("Plan this"). `toolCallId` names the read whose card it was; the
|
||||
|
||||
@@ -27,6 +27,9 @@ export const TOUR_VERB_KEYS = [
|
||||
// A tour pointed at its trip's packing list, or let go of it.
|
||||
'linkPacking',
|
||||
'unlinkPacking',
|
||||
// A found place taken into a saved tour as a stop, or taken out again (P6).
|
||||
'addTourStop',
|
||||
'removeTourStop',
|
||||
] as const;
|
||||
/** 'tour' is a core entity already. */
|
||||
export const TOUR_APPLIED_ENTITIES = [] as const;
|
||||
|
||||
@@ -116,6 +116,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'التحقق من التكاليف',
|
||||
'assistant.step.help': 'البحث في المساعدة',
|
||||
'assistant.step.readingBooking': 'قراءة الحجز',
|
||||
'assistant.step.readingTourFile': 'جارٍ قراءة ملف المسار',
|
||||
'assistant.step.planningHike': 'تخطيط رحلة المشي',
|
||||
'assistant.step.planningHikeTo': 'تخطيط رحلة المشي إلى {place}',
|
||||
'assistant.step.expenses': 'قراءة النفقات',
|
||||
@@ -230,6 +231,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'كان ذلك كثيرًا دفعة واحدة. هل أكمل؟',
|
||||
'assistant.error.offline': 'غير متصل. يحتاج المساعد إلى الإنترنت.',
|
||||
'assistant.error.noBookingsFound': 'لم يُعثر على أي حجز في هذا الملف.',
|
||||
'assistant.error.noTracksFound': 'لم يُعثر على أي مسار في هذا الملف.',
|
||||
'assistant.error.importUnavailable': 'قراءة ملفات الحجز غير متاحة هنا.',
|
||||
'assistant.error.generic': 'حدث خطأ ما.',
|
||||
'assistant.errorAction.retry': 'إعادة المحاولة',
|
||||
@@ -308,6 +310,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'تم الاقتراح',
|
||||
'assistant.place.inTrip': 'موجود في رحلتك بالفعل',
|
||||
'assistant.place.showOnMap': 'على الخريطة',
|
||||
'assistant.place.addToTour': 'إلى الجولة',
|
||||
'assistant.place.addToTourHint': 'إضافة كمحطة إلى {name}',
|
||||
'assistant.place.openNow': 'مفتوح الآن',
|
||||
'assistant.place.closed': 'مغلق',
|
||||
'assistant.place.fromPrev': '{distance} من المحطة السابقة',
|
||||
@@ -499,6 +503,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'ربط قائمة التعبئة',
|
||||
'assistant.change.verb.unlinkPacking': 'فك ربط قائمة التعبئة',
|
||||
'assistant.change.verb.importTour': 'إضافة جولة من ملف',
|
||||
'assistant.change.verb.addTourStop': 'إضافة محطة إلى الجولة',
|
||||
'assistant.change.verb.removeTourStop': 'إزالة محطة من الجولة',
|
||||
'assistant.change.label.field': 'القيمة',
|
||||
'assistant.change.label.difficulty': 'أقصى مستوى لصعوبة الدرب',
|
||||
'assistant.change.label.track': 'مسار مسجل',
|
||||
|
||||
@@ -119,6 +119,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Xərclər yoxlanılır',
|
||||
'assistant.step.help': 'Kömək bölməsində axtarılır',
|
||||
'assistant.step.readingBooking': 'Bron oxunur',
|
||||
'assistant.step.readingTourFile': 'Marşrut faylı oxunur',
|
||||
'assistant.step.planningHike': 'Yürüş planlaşdırılır',
|
||||
'assistant.step.planningHikeTo': '{place} istiqamətində yürüş planlaşdırılır',
|
||||
'assistant.step.expenses': 'Xərclər oxunur',
|
||||
@@ -217,6 +218,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Bir dəfəyə çox oldu. Davam edim?',
|
||||
'assistant.error.offline': 'Oflayn. Köməkçinin internetə ehtiyacı var.',
|
||||
'assistant.error.noBookingsFound': 'Bu faylda bron tapılmadı.',
|
||||
'assistant.error.noTracksFound': 'Bu faylda heç bir trek və ya marşrut tapılmadı.',
|
||||
'assistant.error.importUnavailable': 'Bron fayllarının oxunması burada mümkün deyil.',
|
||||
'assistant.error.generic': 'Nəsə səhv getdi.',
|
||||
'assistant.errorAction.retry': 'Yenidən cəhd et',
|
||||
@@ -284,6 +286,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Təklif edildi',
|
||||
'assistant.place.inTrip': 'Artıq səyahətinizdədir',
|
||||
'assistant.place.showOnMap': 'Xəritədə',
|
||||
'assistant.place.addToTour': 'Tura əlavə et',
|
||||
'assistant.place.addToTourHint': '{name} turuna dayanacaq kimi əlavə et',
|
||||
'assistant.place.openNow': 'İndi açıqdır',
|
||||
'assistant.place.closed': 'Bağlıdır',
|
||||
'assistant.place.fromPrev': 'Əvvəlki dayanacaqdan {distance}',
|
||||
@@ -422,6 +426,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Əşya siyahısını bağla',
|
||||
'assistant.change.verb.unlinkPacking': 'Əşya siyahısını ayır',
|
||||
'assistant.change.verb.importTour': 'Fayldan tur əlavə et',
|
||||
'assistant.change.verb.addTourStop': 'Tura dayanacaq əlavə et',
|
||||
'assistant.change.verb.removeTourStop': 'Turdan dayanacağı sil',
|
||||
'assistant.change.label.field': 'Dəyər',
|
||||
'assistant.change.label.difficulty': 'Maksimum cığır çətinliyi',
|
||||
'assistant.change.label.track': 'Qeydə alınmış marşrut',
|
||||
|
||||
@@ -119,6 +119,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Verificando os custos',
|
||||
'assistant.step.help': 'Consultando a ajuda',
|
||||
'assistant.step.readingBooking': 'Lendo a reserva',
|
||||
'assistant.step.readingTourFile': 'Lendo o arquivo de rota',
|
||||
'assistant.step.planningHike': 'Planejando a trilha',
|
||||
'assistant.step.planningHikeTo': 'Planejando a trilha até {place}',
|
||||
'assistant.step.expenses': 'Lendo as despesas',
|
||||
@@ -217,6 +218,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Foi muita coisa de uma vez. Posso continuar?',
|
||||
'assistant.error.offline': 'Sem conexão. O assistente precisa de internet.',
|
||||
'assistant.error.noBookingsFound': 'Nenhuma reserva encontrada neste arquivo.',
|
||||
'assistant.error.noTracksFound': 'Nenhuma trilha ou rota encontrada neste arquivo.',
|
||||
'assistant.error.importUnavailable': 'A leitura de arquivos de reserva não está disponível aqui.',
|
||||
'assistant.error.generic': 'Algo deu errado.',
|
||||
'assistant.errorAction.retry': 'Tentar novamente',
|
||||
@@ -283,6 +285,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Sugerido',
|
||||
'assistant.place.inTrip': 'Já está na sua viagem',
|
||||
'assistant.place.showOnMap': 'No mapa',
|
||||
'assistant.place.addToTour': 'Ao passeio',
|
||||
'assistant.place.addToTourHint': 'Adicionar como parada em {name}',
|
||||
'assistant.place.openNow': 'Aberto agora',
|
||||
'assistant.place.closed': 'Fechado',
|
||||
'assistant.place.fromPrev': 'A {distance} da parada anterior',
|
||||
@@ -422,6 +426,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Vincular lista de bagagem',
|
||||
'assistant.change.verb.unlinkPacking': 'Desvincular lista de bagagem',
|
||||
'assistant.change.verb.importTour': 'Adicionar passeio a partir de arquivo',
|
||||
'assistant.change.verb.addTourStop': 'Adicionar parada ao passeio',
|
||||
'assistant.change.verb.removeTourStop': 'Remover parada do passeio',
|
||||
'assistant.change.label.field': 'Valor',
|
||||
'assistant.change.label.difficulty': 'Dificuldade máxima da trilha',
|
||||
'assistant.change.label.track': 'Trilha gravada',
|
||||
|
||||
@@ -120,6 +120,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Revisant les despeses',
|
||||
'assistant.step.help': 'Cercant a l’ajuda',
|
||||
'assistant.step.readingBooking': 'Llegint la reserva',
|
||||
'assistant.step.readingTourFile': 'Llegint el fitxer de ruta',
|
||||
'assistant.step.planningHike': 'Planificant l’excursió',
|
||||
'assistant.step.planningHikeTo': 'Planificant l’excursió fins a {place}',
|
||||
'assistant.step.expenses': 'Llegint les despeses',
|
||||
@@ -218,6 +219,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Ha estat molt de cop. Continuo?',
|
||||
'assistant.error.offline': 'Sense connexió. L’assistent necessita internet.',
|
||||
'assistant.error.noBookingsFound': 'No s’ha trobat cap reserva en aquest fitxer.',
|
||||
'assistant.error.noTracksFound': 'No s’ha trobat cap traç ni ruta en aquest fitxer.',
|
||||
'assistant.error.importUnavailable': 'La lectura de fitxers de reserva no està disponible aquí.',
|
||||
'assistant.error.generic': 'Alguna cosa ha fallat.',
|
||||
'assistant.errorAction.retry': 'Torna-ho a provar',
|
||||
@@ -286,6 +288,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Proposat',
|
||||
'assistant.place.inTrip': 'Ja és al teu viatge',
|
||||
'assistant.place.showOnMap': 'Al mapa',
|
||||
'assistant.place.addToTour': 'A la ruta',
|
||||
'assistant.place.addToTourHint': 'Afegeix com a parada a {name}',
|
||||
'assistant.place.openNow': 'Obert ara',
|
||||
'assistant.place.closed': 'Tancat',
|
||||
'assistant.place.fromPrev': 'A {distance} de la parada anterior',
|
||||
@@ -425,6 +429,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Enllaça la llista d’equipatge',
|
||||
'assistant.change.verb.unlinkPacking': 'Desenllaça la llista d’equipatge',
|
||||
'assistant.change.verb.importTour': 'Afegeix una excursió des d’un fitxer',
|
||||
'assistant.change.verb.addTourStop': 'Afegeix una parada a la ruta',
|
||||
'assistant.change.verb.removeTourStop': 'Treu una parada de la ruta',
|
||||
'assistant.change.label.field': 'Valor',
|
||||
'assistant.change.label.difficulty': 'Dificultat màxima del sender',
|
||||
'assistant.change.label.track': 'Traç enregistrat',
|
||||
|
||||
@@ -118,6 +118,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Kontroluji náklady',
|
||||
'assistant.step.help': 'Hledám v nápovědě',
|
||||
'assistant.step.readingBooking': 'Čtu rezervaci',
|
||||
'assistant.step.readingTourFile': 'Čtu soubor s trasou',
|
||||
'assistant.step.planningHike': 'Plánuji túru',
|
||||
'assistant.step.planningHikeTo': 'Plánuji túru do místa {place}',
|
||||
'assistant.step.expenses': 'Čtu výdaje',
|
||||
@@ -220,6 +221,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Bylo toho najednou hodně. Mám pokračovat?',
|
||||
'assistant.error.offline': 'Offline. Asistent potřebuje internet.',
|
||||
'assistant.error.noBookingsFound': 'V tomto souboru nebyla nalezena žádná rezervace.',
|
||||
'assistant.error.noTracksFound': 'V tomto souboru nebyla nalezena žádná stopa ani trasa.',
|
||||
'assistant.error.importUnavailable': 'Čtení souborů s rezervacemi tu není k dispozici.',
|
||||
'assistant.error.generic': 'Něco se pokazilo.',
|
||||
'assistant.errorAction.retry': 'Zkusit znovu',
|
||||
@@ -289,6 +291,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Navrženo',
|
||||
'assistant.place.inTrip': 'Už je ve tvém výletu',
|
||||
'assistant.place.showOnMap': 'Na mapě',
|
||||
'assistant.place.addToTour': 'Do túry',
|
||||
'assistant.place.addToTourHint': 'Přidat jako zastávku do {name}',
|
||||
'assistant.place.openNow': 'Teď otevřeno',
|
||||
'assistant.place.closed': 'Zavřeno',
|
||||
'assistant.place.fromPrev': '{distance} od předchozí zastávky',
|
||||
@@ -440,6 +444,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Propojit seznam balení',
|
||||
'assistant.change.verb.unlinkPacking': 'Odpojit seznam balení',
|
||||
'assistant.change.verb.importTour': 'Přidat výlet ze souboru',
|
||||
'assistant.change.verb.addTourStop': 'Přidat zastávku do túry',
|
||||
'assistant.change.verb.removeTourStop': 'Odebrat zastávku z túry',
|
||||
'assistant.change.label.field': 'Hodnota',
|
||||
'assistant.change.label.difficulty': 'Maximální obtížnost stezky',
|
||||
'assistant.change.label.track': 'Zaznamenaná trasa',
|
||||
|
||||
@@ -119,6 +119,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Prüft die Kosten',
|
||||
'assistant.step.help': 'Schaut in der Hilfe nach',
|
||||
'assistant.step.readingBooking': 'Liest die Buchung',
|
||||
'assistant.step.readingTourFile': 'Liest die Routendatei',
|
||||
'assistant.step.planningHike': 'Plant die Wanderung',
|
||||
'assistant.step.planningHikeTo': 'Plant die Wanderung bis {place}',
|
||||
'assistant.step.expenses': 'Liest die Ausgaben',
|
||||
@@ -217,6 +218,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Das war viel auf einmal. Soll ich weitermachen?',
|
||||
'assistant.error.offline': 'Offline. Der Assistent braucht Internet.',
|
||||
'assistant.error.noBookingsFound': 'In dieser Datei wurde keine Buchung gefunden.',
|
||||
'assistant.error.noTracksFound': 'In dieser Datei wurde keine Strecke und keine Route gefunden.',
|
||||
'assistant.error.importUnavailable': 'Buchungsdateien lesen ist hier nicht verfügbar.',
|
||||
'assistant.error.generic': 'Etwas ist schiefgelaufen.',
|
||||
'assistant.errorAction.retry': 'Nochmal versuchen',
|
||||
@@ -283,6 +285,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Vorgeschlagen',
|
||||
'assistant.place.inTrip': 'Schon im Trip',
|
||||
'assistant.place.showOnMap': 'Auf Karte',
|
||||
'assistant.place.addToTour': 'Zur Tour',
|
||||
'assistant.place.addToTourHint': 'Als Halt zu {name} hinzufügen',
|
||||
'assistant.place.openNow': 'Geöffnet',
|
||||
'assistant.place.closed': 'Geschlossen',
|
||||
'assistant.place.fromPrev': '{distance} vom vorigen Stopp',
|
||||
@@ -422,6 +426,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Packliste verknüpfen',
|
||||
'assistant.change.verb.unlinkPacking': 'Packliste lösen',
|
||||
'assistant.change.verb.importTour': 'Tour aus Datei hinzufügen',
|
||||
'assistant.change.verb.addTourStop': 'Halt zur Tour hinzufügen',
|
||||
'assistant.change.verb.removeTourStop': 'Halt aus der Tour entfernen',
|
||||
'assistant.change.label.field': 'Wert',
|
||||
'assistant.change.label.difficulty': 'Maximale Wegschwierigkeit',
|
||||
'assistant.change.label.track': 'Aufzeichnung',
|
||||
|
||||
@@ -115,6 +115,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Checking the costs',
|
||||
'assistant.step.help': 'Looking it up in the help',
|
||||
'assistant.step.readingBooking': 'Reading the booking',
|
||||
'assistant.step.readingTourFile': 'Reading the route file',
|
||||
'assistant.step.planningHike': 'Planning the hike',
|
||||
'assistant.step.planningHikeTo': 'Planning the hike to {place}',
|
||||
'assistant.step.expenses': 'Reading the expenses',
|
||||
@@ -213,6 +214,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'That was a lot at once. Shall I continue?',
|
||||
'assistant.error.offline': 'Offline. The assistant needs internet.',
|
||||
'assistant.error.noBookingsFound': 'No booking found in this file.',
|
||||
'assistant.error.noTracksFound': 'No track or route found in this file.',
|
||||
'assistant.error.importUnavailable': 'Reading booking files isn’t available here.',
|
||||
'assistant.error.generic': 'Something went wrong.',
|
||||
'assistant.errorAction.retry': 'Try again',
|
||||
@@ -279,6 +281,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Suggested',
|
||||
'assistant.place.inTrip': 'Already in your trip',
|
||||
'assistant.place.showOnMap': 'On map',
|
||||
'assistant.place.addToTour': 'Add to tour',
|
||||
'assistant.place.addToTourHint': 'Add as a stop to {name}',
|
||||
'assistant.place.openNow': 'Open now',
|
||||
'assistant.place.closed': 'Closed',
|
||||
'assistant.place.fromPrev': '{distance} from the previous stop',
|
||||
@@ -416,6 +420,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Link packing list',
|
||||
'assistant.change.verb.unlinkPacking': 'Unlink packing list',
|
||||
'assistant.change.verb.importTour': 'Add tour from file',
|
||||
'assistant.change.verb.addTourStop': 'Add stop to tour',
|
||||
'assistant.change.verb.removeTourStop': 'Remove stop from tour',
|
||||
'assistant.change.label.field': 'Value',
|
||||
'assistant.change.label.difficulty': 'Maximum trail difficulty',
|
||||
'assistant.change.label.track': 'Recorded track',
|
||||
|
||||
@@ -118,6 +118,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Revisando los gastos',
|
||||
'assistant.step.help': 'Buscando en la ayuda',
|
||||
'assistant.step.readingBooking': 'Leyendo la reserva',
|
||||
'assistant.step.readingTourFile': 'Leyendo el archivo de ruta',
|
||||
'assistant.step.planningHike': 'Planificando la excursión',
|
||||
'assistant.step.planningHikeTo': 'Planificando la excursión hasta {place}',
|
||||
'assistant.step.expenses': 'Leyendo los gastos',
|
||||
@@ -216,6 +217,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Ha sido mucho de golpe. ¿Sigo?',
|
||||
'assistant.error.offline': 'Sin conexión. El asistente necesita internet.',
|
||||
'assistant.error.noBookingsFound': 'No se encontró ninguna reserva en este archivo.',
|
||||
'assistant.error.noTracksFound': 'No se encontró ningún track ni ruta en este archivo.',
|
||||
'assistant.error.importUnavailable': 'La lectura de archivos de reserva no está disponible aquí.',
|
||||
'assistant.error.generic': 'Algo ha salido mal.',
|
||||
'assistant.errorAction.retry': 'Reintentar',
|
||||
@@ -282,6 +284,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Propuesto',
|
||||
'assistant.place.inTrip': 'Ya está en tu viaje',
|
||||
'assistant.place.showOnMap': 'En el mapa',
|
||||
'assistant.place.addToTour': 'A la ruta',
|
||||
'assistant.place.addToTourHint': 'Añadir como parada a {name}',
|
||||
'assistant.place.openNow': 'Abierto ahora',
|
||||
'assistant.place.closed': 'Cerrado',
|
||||
'assistant.place.fromPrev': 'A {distance} de la parada anterior',
|
||||
@@ -420,6 +424,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Vincular lista de equipaje',
|
||||
'assistant.change.verb.unlinkPacking': 'Desvincular lista de equipaje',
|
||||
'assistant.change.verb.importTour': 'Añadir excursión desde archivo',
|
||||
'assistant.change.verb.addTourStop': 'Añadir parada a la ruta',
|
||||
'assistant.change.verb.removeTourStop': 'Quitar parada de la ruta',
|
||||
'assistant.change.label.field': 'Valor',
|
||||
'assistant.change.label.difficulty': 'Dificultad máxima del sendero',
|
||||
'assistant.change.label.track': 'Track grabado',
|
||||
|
||||
@@ -119,6 +119,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Kontrollin kulusid',
|
||||
'assistant.step.help': 'Otsin abist',
|
||||
'assistant.step.readingBooking': 'Loen broneeringut',
|
||||
'assistant.step.readingTourFile': 'Loen marsruudifaili',
|
||||
'assistant.step.planningHike': 'Planeerin matka',
|
||||
'assistant.step.planningHikeTo': 'Planeerin matka sihtkohta {place}',
|
||||
'assistant.step.expenses': 'Loen kulusid',
|
||||
@@ -217,6 +218,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Seda oli korraga palju. Kas jätkan?',
|
||||
'assistant.error.offline': 'Võrguühendus puudub. Assistent vajab internetti.',
|
||||
'assistant.error.noBookingsFound': 'Sellest failist ei leitud ühtegi broneeringut.',
|
||||
'assistant.error.noTracksFound': 'Sellest failist ei leitud ühtegi rada ega marsruuti.',
|
||||
'assistant.error.importUnavailable': 'Broneeringufailide lugemine pole siin saadaval.',
|
||||
'assistant.error.generic': 'Midagi läks valesti.',
|
||||
'assistant.errorAction.retry': 'Proovi uuesti',
|
||||
@@ -283,6 +285,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Soovitatud',
|
||||
'assistant.place.inTrip': 'Juba sinu reisil',
|
||||
'assistant.place.showOnMap': 'Kaardil',
|
||||
'assistant.place.addToTour': 'Matkale',
|
||||
'assistant.place.addToTourHint': 'Lisa peatusena matkale {name}',
|
||||
'assistant.place.openNow': 'Praegu avatud',
|
||||
'assistant.place.closed': 'Suletud',
|
||||
'assistant.place.fromPrev': '{distance} eelmisest peatusest',
|
||||
@@ -421,6 +425,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Seo pakkimisnimekiri',
|
||||
'assistant.change.verb.unlinkPacking': 'Eemalda pakkimisnimekirja seos',
|
||||
'assistant.change.verb.importTour': 'Lisa retk failist',
|
||||
'assistant.change.verb.addTourStop': 'Lisa matkale peatus',
|
||||
'assistant.change.verb.removeTourStop': 'Eemalda matkalt peatus',
|
||||
'assistant.change.label.field': 'Väärtus',
|
||||
'assistant.change.label.difficulty': 'Raja suurim raskusaste',
|
||||
'assistant.change.label.track': 'Salvestatud rada',
|
||||
|
||||
@@ -120,6 +120,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Vérification des dépenses',
|
||||
'assistant.step.help': 'Recherche dans l’aide',
|
||||
'assistant.step.readingBooking': 'Lecture de la réservation',
|
||||
'assistant.step.readingTourFile': 'Lecture du fichier d’itinéraire',
|
||||
'assistant.step.planningHike': 'Planification de la randonnée',
|
||||
'assistant.step.planningHikeTo': 'Planification de la randonnée jusqu’à {place}',
|
||||
'assistant.step.expenses': 'Lecture des dépenses',
|
||||
@@ -218,6 +219,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Ça faisait beaucoup d’un coup. Je continue ?',
|
||||
'assistant.error.offline': 'Hors ligne. L’assistant a besoin d’Internet.',
|
||||
'assistant.error.noBookingsFound': 'Aucune réservation trouvée dans ce fichier.',
|
||||
'assistant.error.noTracksFound': 'Aucune trace ni aucun itinéraire trouvé dans ce fichier.',
|
||||
'assistant.error.importUnavailable': 'La lecture des fichiers de réservation n’est pas disponible ici.',
|
||||
'assistant.error.generic': 'Une erreur s’est produite.',
|
||||
'assistant.errorAction.retry': 'Réessayer',
|
||||
@@ -287,6 +289,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Proposé',
|
||||
'assistant.place.inTrip': 'Déjà dans votre voyage',
|
||||
'assistant.place.showOnMap': 'Sur la carte',
|
||||
'assistant.place.addToTour': 'À la sortie',
|
||||
'assistant.place.addToTourHint': 'Ajouter comme étape à {name}',
|
||||
'assistant.place.openNow': 'Ouvert',
|
||||
'assistant.place.closed': 'Fermé',
|
||||
'assistant.place.fromPrev': '{distance} de l’étape précédente',
|
||||
@@ -427,6 +431,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Lier la liste de bagages',
|
||||
'assistant.change.verb.unlinkPacking': 'Délier la liste de bagages',
|
||||
'assistant.change.verb.importTour': 'Ajouter une sortie depuis un fichier',
|
||||
'assistant.change.verb.addTourStop': 'Ajouter une étape à la sortie',
|
||||
'assistant.change.verb.removeTourStop': 'Retirer une étape de la sortie',
|
||||
'assistant.change.label.field': 'Valeur',
|
||||
'assistant.change.label.difficulty': 'Difficulté maximale du sentier',
|
||||
'assistant.change.label.track': 'Trace enregistrée',
|
||||
|
||||
@@ -122,6 +122,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Έλεγχος εξόδων',
|
||||
'assistant.step.help': 'Αναζήτηση στη βοήθεια',
|
||||
'assistant.step.readingBooking': 'Διαβάζω την κράτηση',
|
||||
'assistant.step.readingTourFile': 'Διαβάζω το αρχείο διαδρομής',
|
||||
'assistant.step.planningHike': 'Σχεδιάζω την πεζοπορία',
|
||||
'assistant.step.planningHikeTo': 'Σχεδιάζω την πεζοπορία προς {place}',
|
||||
'assistant.step.expenses': 'Διαβάζω τα έξοδα',
|
||||
@@ -220,6 +221,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Ήταν πολλά μαζί. Να συνεχίσω;',
|
||||
'assistant.error.offline': 'Εκτός σύνδεσης. Ο βοηθός χρειάζεται διαδίκτυο.',
|
||||
'assistant.error.noBookingsFound': 'Δεν βρέθηκε κράτηση σε αυτό το αρχείο.',
|
||||
'assistant.error.noTracksFound': 'Δεν βρέθηκε ίχνος ή διαδρομή σε αυτό το αρχείο.',
|
||||
'assistant.error.importUnavailable': 'Η ανάγνωση αρχείων κράτησης δεν είναι διαθέσιμη εδώ.',
|
||||
'assistant.error.generic': 'Κάτι πήγε στραβά.',
|
||||
'assistant.errorAction.retry': 'Δοκιμή ξανά',
|
||||
@@ -288,6 +290,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Προτάθηκε',
|
||||
'assistant.place.inTrip': 'Ήδη στο ταξίδι σας',
|
||||
'assistant.place.showOnMap': 'Στον χάρτη',
|
||||
'assistant.place.addToTour': 'Στην εκδρομή',
|
||||
'assistant.place.addToTourHint': 'Προσθήκη ως στάση στο {name}',
|
||||
'assistant.place.openNow': 'Ανοιχτό τώρα',
|
||||
'assistant.place.closed': 'Κλειστό',
|
||||
'assistant.place.fromPrev': '{distance} από την προηγούμενη στάση',
|
||||
@@ -426,6 +430,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Σύνδεση λίστας αποσκευών',
|
||||
'assistant.change.verb.unlinkPacking': 'Αποσύνδεση λίστας αποσκευών',
|
||||
'assistant.change.verb.importTour': 'Προσθήκη εκδρομής από αρχείο',
|
||||
'assistant.change.verb.addTourStop': 'Προσθήκη στάσης στην εκδρομή',
|
||||
'assistant.change.verb.removeTourStop': 'Αφαίρεση στάσης από την εκδρομή',
|
||||
'assistant.change.label.field': 'Τιμή',
|
||||
'assistant.change.label.difficulty': 'Μέγιστη δυσκολία μονοπατιού',
|
||||
'assistant.change.label.track': 'Καταγεγραμμένη διαδρομή',
|
||||
|
||||
@@ -121,6 +121,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'A költségek ellenőrzése',
|
||||
'assistant.step.help': 'Utánanézés a súgóban',
|
||||
'assistant.step.readingBooking': 'A foglalás olvasása',
|
||||
'assistant.step.readingTourFile': 'Útvonalfájl beolvasása',
|
||||
'assistant.step.planningHike': 'A túra tervezése',
|
||||
'assistant.step.planningHikeTo': 'Túra tervezése ide: {place}',
|
||||
'assistant.step.expenses': 'A kiadások olvasása',
|
||||
@@ -219,6 +220,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Ez egyszerre sok volt. Folytassam?',
|
||||
'assistant.error.offline': 'Offline vagy. Az asszisztensnek internet kell.',
|
||||
'assistant.error.noBookingsFound': 'Ebben a fájlban nem található foglalás.',
|
||||
'assistant.error.noTracksFound': 'Ebben a fájlban nem található nyomvonal vagy útvonal.',
|
||||
'assistant.error.importUnavailable': 'A foglalási fájlok olvasása itt nem érhető el.',
|
||||
'assistant.error.generic': 'Valami hiba történt.',
|
||||
'assistant.errorAction.retry': 'Újra',
|
||||
@@ -288,6 +290,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Javasolva',
|
||||
'assistant.place.inTrip': 'Már az utadban van',
|
||||
'assistant.place.showOnMap': 'Térképen',
|
||||
'assistant.place.addToTour': 'A túrához',
|
||||
'assistant.place.addToTourHint': 'Hozzáadás megállóként ehhez: {name}',
|
||||
'assistant.place.openNow': 'Most nyitva',
|
||||
'assistant.place.closed': 'Zárva',
|
||||
'assistant.place.fromPrev': '{distance} az előző megállótól',
|
||||
@@ -427,6 +431,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Csomaglista összekapcsolása',
|
||||
'assistant.change.verb.unlinkPacking': 'Csomaglista leválasztása',
|
||||
'assistant.change.verb.importTour': 'Túra hozzáadása fájlból',
|
||||
'assistant.change.verb.addTourStop': 'Megálló hozzáadása a túrához',
|
||||
'assistant.change.verb.removeTourStop': 'Megálló eltávolítása a túrából',
|
||||
'assistant.change.label.field': 'Érték',
|
||||
'assistant.change.label.difficulty': 'Ösvény legnagyobb nehézsége',
|
||||
'assistant.change.label.track': 'Rögzített nyomvonal',
|
||||
|
||||
@@ -116,6 +116,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Memeriksa biaya',
|
||||
'assistant.step.help': 'Mencari di bantuan',
|
||||
'assistant.step.readingBooking': 'Membaca pemesanan',
|
||||
'assistant.step.readingTourFile': 'Membaca berkas rute',
|
||||
'assistant.step.planningHike': 'Merencanakan pendakian',
|
||||
'assistant.step.planningHikeTo': 'Merencanakan pendakian ke {place}',
|
||||
'assistant.step.expenses': 'Membaca pengeluaran',
|
||||
@@ -210,6 +211,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Itu banyak sekaligus. Mau aku lanjutkan?',
|
||||
'assistant.error.offline': 'Sedang offline. Asisten butuh internet.',
|
||||
'assistant.error.noBookingsFound': 'Tidak ada pemesanan yang ditemukan di file ini.',
|
||||
'assistant.error.noTracksFound': 'Tidak ada jalur atau rute di berkas ini.',
|
||||
'assistant.error.importUnavailable': 'Membaca file pemesanan tidak tersedia di sini.',
|
||||
'assistant.error.generic': 'Terjadi kesalahan.',
|
||||
'assistant.errorAction.retry': 'Coba lagi',
|
||||
@@ -275,6 +277,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Diusulkan',
|
||||
'assistant.place.inTrip': 'Sudah ada di perjalananmu',
|
||||
'assistant.place.showOnMap': 'Di peta',
|
||||
'assistant.place.addToTour': 'Ke tur',
|
||||
'assistant.place.addToTourHint': 'Tambahkan sebagai perhentian ke {name}',
|
||||
'assistant.place.openNow': 'Buka sekarang',
|
||||
'assistant.place.closed': 'Tutup',
|
||||
'assistant.place.fromPrev': '{distance} dari pemberhentian sebelumnya',
|
||||
@@ -401,6 +405,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Tautkan daftar bawaan',
|
||||
'assistant.change.verb.unlinkPacking': 'Lepas tautan daftar bawaan',
|
||||
'assistant.change.verb.importTour': 'Tambah tur dari file',
|
||||
'assistant.change.verb.addTourStop': 'Tambahkan perhentian ke tur',
|
||||
'assistant.change.verb.removeTourStop': 'Hapus perhentian dari tur',
|
||||
'assistant.change.label.field': 'Nilai',
|
||||
'assistant.change.label.difficulty': 'Tingkat kesulitan jalur maksimum',
|
||||
'assistant.change.label.track': 'Jejak terekam',
|
||||
|
||||
@@ -121,6 +121,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Controllo i costi',
|
||||
'assistant.step.help': 'Cerco nella guida',
|
||||
'assistant.step.readingBooking': 'Leggo la prenotazione',
|
||||
'assistant.step.readingTourFile': 'Lettura del file del percorso',
|
||||
'assistant.step.planningHike': 'Pianifico l’escursione',
|
||||
'assistant.step.planningHikeTo': 'Pianifico l’escursione fino a {place}',
|
||||
'assistant.step.expenses': 'Leggo le spese',
|
||||
@@ -219,6 +220,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Era tanto tutto insieme. Continuo?',
|
||||
'assistant.error.offline': 'Sei offline. L’assistente ha bisogno di internet.',
|
||||
'assistant.error.noBookingsFound': 'Nessuna prenotazione trovata in questo file.',
|
||||
'assistant.error.noTracksFound': 'Nessuna traccia o percorso trovato in questo file.',
|
||||
'assistant.error.importUnavailable': 'La lettura dei file di prenotazione non è disponibile qui.',
|
||||
'assistant.error.generic': 'Qualcosa è andato storto.',
|
||||
'assistant.errorAction.retry': 'Riprova',
|
||||
@@ -286,6 +288,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Proposto',
|
||||
'assistant.place.inTrip': 'Già nel tuo viaggio',
|
||||
'assistant.place.showOnMap': 'Sulla mappa',
|
||||
'assistant.place.addToTour': 'Al tour',
|
||||
'assistant.place.addToTourHint': 'Aggiungi come tappa a {name}',
|
||||
'assistant.place.openNow': 'Aperto ora',
|
||||
'assistant.place.closed': 'Chiuso',
|
||||
'assistant.place.fromPrev': 'A {distance} dalla tappa precedente',
|
||||
@@ -428,6 +432,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Collega lista bagagli',
|
||||
'assistant.change.verb.unlinkPacking': 'Scollega lista bagagli',
|
||||
'assistant.change.verb.importTour': 'Aggiungi escursione da file',
|
||||
'assistant.change.verb.addTourStop': 'Aggiungi tappa al tour',
|
||||
'assistant.change.verb.removeTourStop': 'Rimuovi tappa dal tour',
|
||||
'assistant.change.label.field': 'Valore',
|
||||
'assistant.change.label.difficulty': 'Difficoltà massima del sentiero',
|
||||
'assistant.change.label.track': 'Traccia registrata',
|
||||
|
||||
@@ -116,6 +116,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': '費用を確認中',
|
||||
'assistant.step.help': 'ヘルプを検索中',
|
||||
'assistant.step.readingBooking': '予約を読み込み中',
|
||||
'assistant.step.readingTourFile': 'ルートファイルを読み込み中',
|
||||
'assistant.step.planningHike': 'ハイキングを計画中',
|
||||
'assistant.step.planningHikeTo': '{place}までのハイキングを計画中',
|
||||
'assistant.step.expenses': '支出を読み込み中',
|
||||
@@ -210,6 +211,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': '一度にたくさん処理しました。続けますか?',
|
||||
'assistant.error.offline': 'オフラインです。アシスタントにはインターネット接続が必要です。',
|
||||
'assistant.error.noBookingsFound': 'このファイルに予約は見つかりませんでした。',
|
||||
'assistant.error.noTracksFound': 'このファイルにトラックやルートが見つかりませんでした。',
|
||||
'assistant.error.importUnavailable': 'ここでは予約ファイルを読み込めません。',
|
||||
'assistant.error.generic': '問題が発生しました。',
|
||||
'assistant.errorAction.retry': '再試行',
|
||||
@@ -274,6 +276,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': '提案済み',
|
||||
'assistant.place.inTrip': 'すでに旅行に含まれています',
|
||||
'assistant.place.showOnMap': '地図で表示',
|
||||
'assistant.place.addToTour': 'ツアーへ',
|
||||
'assistant.place.addToTourHint': '{name} に立ち寄り先として追加',
|
||||
'assistant.place.openNow': '営業中',
|
||||
'assistant.place.closed': '休業',
|
||||
'assistant.place.fromPrev': '前の立ち寄り先から{distance}',
|
||||
@@ -396,6 +400,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': '持ち物リストを紐付け',
|
||||
'assistant.change.verb.unlinkPacking': '持ち物リストの紐付けを解除',
|
||||
'assistant.change.verb.importTour': 'ファイルからツアーを追加',
|
||||
'assistant.change.verb.addTourStop': 'ツアーに立ち寄り先を追加',
|
||||
'assistant.change.verb.removeTourStop': 'ツアーから立ち寄り先を削除',
|
||||
'assistant.change.label.field': '値',
|
||||
'assistant.change.label.difficulty': '登山道の難易度上限',
|
||||
'assistant.change.label.track': '記録トラック',
|
||||
|
||||
@@ -114,6 +114,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': '비용을 확인하는 중',
|
||||
'assistant.step.help': '도움말을 찾는 중',
|
||||
'assistant.step.readingBooking': '예약을 읽는 중',
|
||||
'assistant.step.readingTourFile': '경로 파일 읽는 중',
|
||||
'assistant.step.planningHike': '하이킹 계획 중',
|
||||
'assistant.step.planningHikeTo': '{place}까지 하이킹 계획 중',
|
||||
'assistant.step.expenses': '지출을 읽는 중',
|
||||
@@ -208,6 +209,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': '한 번에 많은 작업을 했습니다. 계속할까요?',
|
||||
'assistant.error.offline': '오프라인입니다. 어시스턴트는 인터넷이 필요합니다.',
|
||||
'assistant.error.noBookingsFound': '이 파일에서 예약을 찾지 못했습니다.',
|
||||
'assistant.error.noTracksFound': '이 파일에서 트랙이나 경로를 찾지 못했습니다.',
|
||||
'assistant.error.importUnavailable': '여기서는 예약 파일을 읽을 수 없습니다.',
|
||||
'assistant.error.generic': '문제가 발생했습니다.',
|
||||
'assistant.errorAction.retry': '다시 시도',
|
||||
@@ -271,6 +273,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': '제안됨',
|
||||
'assistant.place.inTrip': '이미 여행에 있음',
|
||||
'assistant.place.showOnMap': '지도에서',
|
||||
'assistant.place.addToTour': '투어에 추가',
|
||||
'assistant.place.addToTourHint': '{name}에 경유지로 추가',
|
||||
'assistant.place.openNow': '영업 중',
|
||||
'assistant.place.closed': '영업 종료',
|
||||
'assistant.place.fromPrev': '이전 장소에서 {distance}',
|
||||
@@ -392,6 +396,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': '짐 목록 연결',
|
||||
'assistant.change.verb.unlinkPacking': '짐 목록 연결 해제',
|
||||
'assistant.change.verb.importTour': '파일에서 투어 추가',
|
||||
'assistant.change.verb.addTourStop': '투어에 경유지 추가',
|
||||
'assistant.change.verb.removeTourStop': '투어에서 경유지 삭제',
|
||||
'assistant.change.label.field': '값',
|
||||
'assistant.change.label.difficulty': '탐방로 최대 난이도',
|
||||
'assistant.change.label.track': '기록된 트랙',
|
||||
|
||||
@@ -119,6 +119,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'De kosten controleren',
|
||||
'assistant.step.help': 'In de help opzoeken',
|
||||
'assistant.step.readingBooking': 'Boeking lezen',
|
||||
'assistant.step.readingTourFile': 'Routebestand lezen',
|
||||
'assistant.step.planningHike': 'Wandeling plannen',
|
||||
'assistant.step.planningHikeTo': 'Plant de wandeling naar {place}',
|
||||
'assistant.step.expenses': 'De uitgaven lezen',
|
||||
@@ -217,6 +218,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Dat was veel tegelijk. Zal ik doorgaan?',
|
||||
'assistant.error.offline': 'Offline. De assistent heeft internet nodig.',
|
||||
'assistant.error.noBookingsFound': 'Geen boeking gevonden in dit bestand.',
|
||||
'assistant.error.noTracksFound': 'Geen track of route gevonden in dit bestand.',
|
||||
'assistant.error.importUnavailable': 'Boekingsbestanden lezen is hier niet beschikbaar.',
|
||||
'assistant.error.generic': 'Er ging iets mis.',
|
||||
'assistant.errorAction.retry': 'Opnieuw proberen',
|
||||
@@ -284,6 +286,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Voorgesteld',
|
||||
'assistant.place.inTrip': 'Al in je reis',
|
||||
'assistant.place.showOnMap': 'Op de kaart',
|
||||
'assistant.place.addToTour': 'Naar tocht',
|
||||
'assistant.place.addToTourHint': 'Als stop toevoegen aan {name}',
|
||||
'assistant.place.openNow': 'Nu open',
|
||||
'assistant.place.closed': 'Gesloten',
|
||||
'assistant.place.fromPrev': '{distance} van de vorige stop',
|
||||
@@ -423,6 +427,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Paklijst koppelen',
|
||||
'assistant.change.verb.unlinkPacking': 'Paklijst ontkoppelen',
|
||||
'assistant.change.verb.importTour': 'Tocht uit bestand toevoegen',
|
||||
'assistant.change.verb.addTourStop': 'Stop aan tocht toevoegen',
|
||||
'assistant.change.verb.removeTourStop': 'Stop uit tocht verwijderen',
|
||||
'assistant.change.label.field': 'Waarde',
|
||||
'assistant.change.label.difficulty': 'Maximale moeilijkheid van het pad',
|
||||
'assistant.change.label.track': 'Opgenomen track',
|
||||
|
||||
@@ -120,6 +120,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Sprawdzam koszty',
|
||||
'assistant.step.help': 'Szukam w pomocy',
|
||||
'assistant.step.readingBooking': 'Czytam rezerwację',
|
||||
'assistant.step.readingTourFile': 'Odczytuję plik trasy',
|
||||
'assistant.step.planningHike': 'Planuję wędrówkę',
|
||||
'assistant.step.planningHikeTo': 'Planuje wędrówkę do celu: {place}',
|
||||
'assistant.step.expenses': 'Czytam wydatki',
|
||||
@@ -226,6 +227,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'To było dużo naraz. Kontynuować?',
|
||||
'assistant.error.offline': 'Brak sieci. Asystent potrzebuje internetu.',
|
||||
'assistant.error.noBookingsFound': 'W tym pliku nie znaleziono żadnej rezerwacji.',
|
||||
'assistant.error.noTracksFound': 'W tym pliku nie znaleziono żadnego śladu ani trasy.',
|
||||
'assistant.error.importUnavailable': 'Odczyt plików z rezerwacjami nie jest tu dostępny.',
|
||||
'assistant.error.generic': 'Coś poszło nie tak.',
|
||||
'assistant.errorAction.retry': 'Spróbuj ponownie',
|
||||
@@ -299,6 +301,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Zaproponowano',
|
||||
'assistant.place.inTrip': 'Już w twojej podróży',
|
||||
'assistant.place.showOnMap': 'Na mapie',
|
||||
'assistant.place.addToTour': 'Do wycieczki',
|
||||
'assistant.place.addToTourHint': 'Dodaj jako przystanek do {name}',
|
||||
'assistant.place.openNow': 'Teraz otwarte',
|
||||
'assistant.place.closed': 'Zamknięte',
|
||||
'assistant.place.fromPrev': '{distance} od poprzedniego przystanku',
|
||||
@@ -465,6 +469,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Połącz listę pakowania',
|
||||
'assistant.change.verb.unlinkPacking': 'Odłącz listę pakowania',
|
||||
'assistant.change.verb.importTour': 'Dodaj wycieczkę z pliku',
|
||||
'assistant.change.verb.addTourStop': 'Dodaj przystanek do wycieczki',
|
||||
'assistant.change.verb.removeTourStop': 'Usuń przystanek z wycieczki',
|
||||
'assistant.change.label.field': 'Wartość',
|
||||
'assistant.change.label.difficulty': 'Maksymalna trudność szlaku',
|
||||
'assistant.change.label.track': 'Nagrany ślad',
|
||||
|
||||
@@ -120,6 +120,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Проверяю расходы',
|
||||
'assistant.step.help': 'Ищу в справке',
|
||||
'assistant.step.readingBooking': 'Читаю бронирование',
|
||||
'assistant.step.readingTourFile': 'Читаю файл маршрута',
|
||||
'assistant.step.planningHike': 'Планирую поход',
|
||||
'assistant.step.planningHikeTo': 'Планирую поход, цель: {place}',
|
||||
'assistant.step.expenses': 'Читаю расходы',
|
||||
@@ -226,6 +227,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Это было много за раз. Продолжить?',
|
||||
'assistant.error.offline': 'Нет сети. Ассистенту нужен интернет.',
|
||||
'assistant.error.noBookingsFound': 'В этом файле не найдено ни одного бронирования.',
|
||||
'assistant.error.noTracksFound': 'В этом файле не найдено ни трека, ни маршрута.',
|
||||
'assistant.error.importUnavailable': 'Чтение файлов бронирований здесь недоступно.',
|
||||
'assistant.error.generic': 'Что-то пошло не так.',
|
||||
'assistant.errorAction.retry': 'Повторить',
|
||||
@@ -298,6 +300,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Предложено',
|
||||
'assistant.place.inTrip': 'Уже в вашей поездке',
|
||||
'assistant.place.showOnMap': 'На карте',
|
||||
'assistant.place.addToTour': 'В тур',
|
||||
'assistant.place.addToTourHint': 'Добавить как остановку в {name}',
|
||||
'assistant.place.openNow': 'Сейчас открыто',
|
||||
'assistant.place.closed': 'Закрыто',
|
||||
'assistant.place.fromPrev': '{distance} от предыдущей остановки',
|
||||
@@ -465,6 +469,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Связать список вещей',
|
||||
'assistant.change.verb.unlinkPacking': 'Отвязать список вещей',
|
||||
'assistant.change.verb.importTour': 'Добавить тур из файла',
|
||||
'assistant.change.verb.addTourStop': 'Добавить остановку в тур',
|
||||
'assistant.change.verb.removeTourStop': 'Убрать остановку из тура',
|
||||
'assistant.change.label.field': 'Значение',
|
||||
'assistant.change.label.difficulty': 'Максимальная сложность тропы',
|
||||
'assistant.change.label.track': 'Записанный трек',
|
||||
|
||||
@@ -118,6 +118,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Kontrolujem náklady',
|
||||
'assistant.step.help': 'Hľadám v pomocníkovi',
|
||||
'assistant.step.readingBooking': 'Čítam rezerváciu',
|
||||
'assistant.step.readingTourFile': 'Čítam súbor s trasou',
|
||||
'assistant.step.planningHike': 'Plánujem túru',
|
||||
'assistant.step.planningHikeTo': 'Plánujem túru do cieľa {place}',
|
||||
'assistant.step.expenses': 'Čítam výdavky',
|
||||
@@ -220,6 +221,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Bolo toho naraz veľa. Mám pokračovať?',
|
||||
'assistant.error.offline': 'Offline. Asistent potrebuje internet.',
|
||||
'assistant.error.noBookingsFound': 'V tomto súbore sa nenašla žiadna rezervácia.',
|
||||
'assistant.error.noTracksFound': 'V tomto súbore sa nenašla žiadna stopa ani trasa.',
|
||||
'assistant.error.importUnavailable': 'Čítanie súborov s rezerváciami tu nie je dostupné.',
|
||||
'assistant.error.generic': 'Niečo sa pokazilo.',
|
||||
'assistant.errorAction.retry': 'Skúsiť znova',
|
||||
@@ -289,6 +291,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Navrhnuté',
|
||||
'assistant.place.inTrip': 'Už je v tvojom výlete',
|
||||
'assistant.place.showOnMap': 'Na mape',
|
||||
'assistant.place.addToTour': 'Do túry',
|
||||
'assistant.place.addToTourHint': 'Pridať ako zastávku do {name}',
|
||||
'assistant.place.openNow': 'Teraz otvorené',
|
||||
'assistant.place.closed': 'Zatvorené',
|
||||
'assistant.place.fromPrev': '{distance} od predošlej zastávky',
|
||||
@@ -440,6 +444,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Prepojiť zoznam balenia',
|
||||
'assistant.change.verb.unlinkPacking': 'Odpojiť zoznam balenia',
|
||||
'assistant.change.verb.importTour': 'Pridať túru zo súboru',
|
||||
'assistant.change.verb.addTourStop': 'Pridať zastávku do túry',
|
||||
'assistant.change.verb.removeTourStop': 'Odobrať zastávku z túry',
|
||||
'assistant.change.label.field': 'Hodnota',
|
||||
'assistant.change.label.difficulty': 'Maximálna náročnosť chodníka',
|
||||
'assistant.change.label.track': 'Zaznamenaná trasa',
|
||||
|
||||
@@ -120,6 +120,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Kontrollerar kostnaderna',
|
||||
'assistant.step.help': 'Letar i hjälpen',
|
||||
'assistant.step.readingBooking': 'Läser bokningen',
|
||||
'assistant.step.readingTourFile': 'Läser ruttfilen',
|
||||
'assistant.step.planningHike': 'Planerar vandringen',
|
||||
'assistant.step.planningHikeTo': 'Planerar vandringen till {place}',
|
||||
'assistant.step.expenses': 'Läser utgifterna',
|
||||
@@ -218,6 +219,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Det var mycket på en gång. Ska jag fortsätta?',
|
||||
'assistant.error.offline': 'Offline. Assistenten behöver internet.',
|
||||
'assistant.error.noBookingsFound': 'Ingen bokning hittades i den här filen.',
|
||||
'assistant.error.noTracksFound': 'Inget spår eller ingen rutt hittades i den här filen.',
|
||||
'assistant.error.importUnavailable': 'Att läsa bokningsfiler är inte tillgängligt här.',
|
||||
'assistant.error.generic': 'Något gick fel.',
|
||||
'assistant.errorAction.retry': 'Försök igen',
|
||||
@@ -284,6 +286,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Föreslagen',
|
||||
'assistant.place.inTrip': 'Redan i din resa',
|
||||
'assistant.place.showOnMap': 'På kartan',
|
||||
'assistant.place.addToTour': 'Till turen',
|
||||
'assistant.place.addToTourHint': 'Lägg till som stopp i {name}',
|
||||
'assistant.place.openNow': 'Öppet nu',
|
||||
'assistant.place.closed': 'Stängt',
|
||||
'assistant.place.fromPrev': '{distance} från förra stoppet',
|
||||
@@ -421,6 +425,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Koppla packlista',
|
||||
'assistant.change.verb.unlinkPacking': 'Koppla från packlista',
|
||||
'assistant.change.verb.importTour': 'Lägg till tur från fil',
|
||||
'assistant.change.verb.addTourStop': 'Lägg till stopp i turen',
|
||||
'assistant.change.verb.removeTourStop': 'Ta bort stopp från turen',
|
||||
'assistant.change.label.field': 'Värde',
|
||||
'assistant.change.label.difficulty': 'Högsta svårighetsgrad för leden',
|
||||
'assistant.change.label.track': 'Inspelat spår',
|
||||
|
||||
@@ -114,6 +114,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'กำลังตรวจสอบค่าใช้จ่าย',
|
||||
'assistant.step.help': 'กำลังค้นในความช่วยเหลือ',
|
||||
'assistant.step.readingBooking': 'กำลังอ่านการจอง',
|
||||
'assistant.step.readingTourFile': 'กำลังอ่านไฟล์เส้นทาง',
|
||||
'assistant.step.planningHike': 'กำลังวางแผนเดินป่า',
|
||||
'assistant.step.planningHikeTo': 'กำลังวางแผนเดินป่าไปยัง {place}',
|
||||
'assistant.step.expenses': 'กำลังอ่านรายจ่าย',
|
||||
@@ -208,6 +209,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'ครั้งนี้เยอะไปหน่อย ให้ฉันทำต่อไหม',
|
||||
'assistant.error.offline': 'ออฟไลน์อยู่ ผู้ช่วยต้องใช้อินเทอร์เน็ต',
|
||||
'assistant.error.noBookingsFound': 'ไม่พบการจองในไฟล์นี้',
|
||||
'assistant.error.noTracksFound': 'ไม่พบแทร็กหรือเส้นทางในไฟล์นี้',
|
||||
'assistant.error.importUnavailable': 'การอ่านไฟล์การจองใช้ไม่ได้ที่นี่',
|
||||
'assistant.error.generic': 'เกิดข้อผิดพลาดบางอย่าง',
|
||||
'assistant.errorAction.retry': 'ลองอีกครั้ง',
|
||||
@@ -271,6 +273,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'เสนอแล้ว',
|
||||
'assistant.place.inTrip': 'อยู่ในทริปของคุณแล้ว',
|
||||
'assistant.place.showOnMap': 'บนแผนที่',
|
||||
'assistant.place.addToTour': 'เพิ่มในทัวร์',
|
||||
'assistant.place.addToTourHint': 'เพิ่มเป็นจุดแวะใน {name}',
|
||||
'assistant.place.openNow': 'เปิดอยู่',
|
||||
'assistant.place.closed': 'ปิด',
|
||||
'assistant.place.fromPrev': '{distance} จากจุดแวะก่อนหน้า',
|
||||
@@ -393,6 +397,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'เชื่อมรายการของที่ต้องเตรียม',
|
||||
'assistant.change.verb.unlinkPacking': 'ยกเลิกการเชื่อมรายการของ',
|
||||
'assistant.change.verb.importTour': 'เพิ่มทัวร์จากไฟล์',
|
||||
'assistant.change.verb.addTourStop': 'เพิ่มจุดแวะในทัวร์',
|
||||
'assistant.change.verb.removeTourStop': 'ลบจุดแวะออกจากทัวร์',
|
||||
'assistant.change.label.field': 'ค่า',
|
||||
'assistant.change.label.difficulty': 'ระดับความยากสูงสุดของทางเดิน',
|
||||
'assistant.change.label.track': 'เส้นทางที่บันทึก',
|
||||
|
||||
@@ -118,6 +118,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Maliyetler kontrol ediliyor',
|
||||
'assistant.step.help': 'Yardımda aranıyor',
|
||||
'assistant.step.readingBooking': 'Rezervasyon okunuyor',
|
||||
'assistant.step.readingTourFile': 'Rota dosyası okunuyor',
|
||||
'assistant.step.planningHike': 'Yürüyüş planlanıyor',
|
||||
'assistant.step.planningHikeTo': '{place} için yürüyüş planlanıyor',
|
||||
'assistant.step.expenses': 'Harcamalar okunuyor',
|
||||
@@ -216,6 +217,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Bir seferde epey fazlaydı. Devam edeyim mi?',
|
||||
'assistant.error.offline': 'Çevrimdışı. Asistanın internete ihtiyacı var.',
|
||||
'assistant.error.noBookingsFound': 'Bu dosyada rezervasyon bulunamadı.',
|
||||
'assistant.error.noTracksFound': 'Bu dosyada iz veya rota bulunamadı.',
|
||||
'assistant.error.importUnavailable': 'Rezervasyon dosyalarını okuma burada kullanılamıyor.',
|
||||
'assistant.error.generic': 'Bir şeyler ters gitti.',
|
||||
'assistant.errorAction.retry': 'Yeniden dene',
|
||||
@@ -282,6 +284,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Önerildi',
|
||||
'assistant.place.inTrip': 'Zaten seyahatinde',
|
||||
'assistant.place.showOnMap': 'Haritada',
|
||||
'assistant.place.addToTour': 'Tura ekle',
|
||||
'assistant.place.addToTourHint': '{name} turuna durak olarak ekle',
|
||||
'assistant.place.openNow': 'Şu an açık',
|
||||
'assistant.place.closed': 'Kapalı',
|
||||
'assistant.place.fromPrev': 'Önceki duraktan {distance}',
|
||||
@@ -421,6 +425,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Bavul listesini bağla',
|
||||
'assistant.change.verb.unlinkPacking': 'Bavul listesinin bağını kaldır',
|
||||
'assistant.change.verb.importTour': 'Dosyadan tur ekle',
|
||||
'assistant.change.verb.addTourStop': 'Tura durak ekle',
|
||||
'assistant.change.verb.removeTourStop': 'Turdan durağı kaldır',
|
||||
'assistant.change.label.field': 'Değer',
|
||||
'assistant.change.label.difficulty': 'En yüksek patika zorluğu',
|
||||
'assistant.change.label.track': 'Kayıtlı iz',
|
||||
|
||||
@@ -120,6 +120,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Перевіряю витрати',
|
||||
'assistant.step.help': 'Шукаю в довідці',
|
||||
'assistant.step.readingBooking': 'Читаю бронювання',
|
||||
'assistant.step.readingTourFile': 'Читаю файл маршруту',
|
||||
'assistant.step.planningHike': 'Планую похід',
|
||||
'assistant.step.planningHikeTo': 'Планую похід, ціль: {place}',
|
||||
'assistant.step.expenses': 'Читаю витрати',
|
||||
@@ -226,6 +227,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Це було забагато за раз. Продовжити?',
|
||||
'assistant.error.offline': 'Немає мережі. Асистентові потрібен інтернет.',
|
||||
'assistant.error.noBookingsFound': 'У цьому файлі не знайдено жодного бронювання.',
|
||||
'assistant.error.noTracksFound': 'У цьому файлі не знайдено ні треку, ні маршруту.',
|
||||
'assistant.error.importUnavailable': 'Читання файлів бронювань тут недоступне.',
|
||||
'assistant.error.generic': 'Щось пішло не так.',
|
||||
'assistant.errorAction.retry': 'Спробувати ще раз',
|
||||
@@ -298,6 +300,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Запропоновано',
|
||||
'assistant.place.inTrip': 'Уже у вашій подорожі',
|
||||
'assistant.place.showOnMap': 'На мапі',
|
||||
'assistant.place.addToTour': 'До туру',
|
||||
'assistant.place.addToTourHint': 'Додати як зупинку до {name}',
|
||||
'assistant.place.openNow': 'Зараз відчинено',
|
||||
'assistant.place.closed': 'Зачинено',
|
||||
'assistant.place.fromPrev': '{distance} від попередньої зупинки',
|
||||
@@ -463,6 +467,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Пов’язати список речей',
|
||||
'assistant.change.verb.unlinkPacking': 'Відв’язати список речей',
|
||||
'assistant.change.verb.importTour': 'Додати тур із файлу',
|
||||
'assistant.change.verb.addTourStop': 'Додати зупинку до туру',
|
||||
'assistant.change.verb.removeTourStop': 'Прибрати зупинку з туру',
|
||||
'assistant.change.label.field': 'Значення',
|
||||
'assistant.change.label.difficulty': 'Максимальна складність стежки',
|
||||
'assistant.change.label.track': 'Записаний трек',
|
||||
|
||||
@@ -116,6 +116,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': 'Đang kiểm tra chi phí',
|
||||
'assistant.step.help': 'Đang tra trong trợ giúp',
|
||||
'assistant.step.readingBooking': 'Đang đọc đặt chỗ',
|
||||
'assistant.step.readingTourFile': 'Đang đọc tệp lộ trình',
|
||||
'assistant.step.planningHike': 'Đang lên kế hoạch đi bộ',
|
||||
'assistant.step.planningHikeTo': 'Đang lên kế hoạch đi bộ đến {place}',
|
||||
'assistant.step.expenses': 'Đang đọc các khoản chi',
|
||||
@@ -210,6 +211,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': 'Một lần hơi nhiều việc. Tôi tiếp tục nhé?',
|
||||
'assistant.error.offline': 'Ngoại tuyến. Trợ lý cần có internet.',
|
||||
'assistant.error.noBookingsFound': 'Không tìm thấy đặt chỗ nào trong tệp này.',
|
||||
'assistant.error.noTracksFound': 'Không tìm thấy đường đi hay lộ trình nào trong tệp này.',
|
||||
'assistant.error.importUnavailable': 'Không thể đọc tệp đặt chỗ ở đây.',
|
||||
'assistant.error.generic': 'Đã xảy ra lỗi.',
|
||||
'assistant.errorAction.retry': 'Thử lại',
|
||||
@@ -273,6 +275,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': 'Đã đề xuất',
|
||||
'assistant.place.inTrip': 'Đã có trong chuyến đi',
|
||||
'assistant.place.showOnMap': 'Trên bản đồ',
|
||||
'assistant.place.addToTour': 'Vào chuyến đi',
|
||||
'assistant.place.addToTourHint': 'Thêm làm điểm dừng vào {name}',
|
||||
'assistant.place.openNow': 'Đang mở cửa',
|
||||
'assistant.place.closed': 'Đã đóng cửa',
|
||||
'assistant.place.fromPrev': 'Cách điểm dừng trước {distance}',
|
||||
@@ -396,6 +400,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': 'Liên kết danh sách đồ',
|
||||
'assistant.change.verb.unlinkPacking': 'Bỏ liên kết danh sách đồ',
|
||||
'assistant.change.verb.importTour': 'Thêm chuyến đi từ tệp',
|
||||
'assistant.change.verb.addTourStop': 'Thêm điểm dừng vào chuyến đi',
|
||||
'assistant.change.verb.removeTourStop': 'Xóa điểm dừng khỏi chuyến đi',
|
||||
'assistant.change.label.field': 'Giá trị',
|
||||
'assistant.change.label.difficulty': 'Độ khó tối đa của đường mòn',
|
||||
'assistant.change.label.track': 'Hành trình đã ghi',
|
||||
|
||||
@@ -111,6 +111,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': '正在檢查費用',
|
||||
'assistant.step.help': '正在說明中查找',
|
||||
'assistant.step.readingBooking': '正在讀取預訂',
|
||||
'assistant.step.readingTourFile': '正在讀取路線檔案',
|
||||
'assistant.step.planningHike': '正在規劃健行路線',
|
||||
'assistant.step.planningHikeTo': '正在規劃前往{place}的健行路線',
|
||||
'assistant.step.expenses': '正在讀取支出',
|
||||
@@ -205,6 +206,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': '一次處理的內容太多了。要我繼續嗎?',
|
||||
'assistant.error.offline': '已離線。助理需要連上網路。',
|
||||
'assistant.error.noBookingsFound': '此檔案中找不到預訂。',
|
||||
'assistant.error.noTracksFound': '此檔案中找不到軌跡或路線。',
|
||||
'assistant.error.importUnavailable': '此處無法讀取預訂檔案。',
|
||||
'assistant.error.generic': '發生錯誤。',
|
||||
'assistant.errorAction.retry': '再試一次',
|
||||
@@ -268,6 +270,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': '已建議',
|
||||
'assistant.place.inTrip': '已在行程中',
|
||||
'assistant.place.showOnMap': '在地圖上',
|
||||
'assistant.place.addToTour': '加入路線',
|
||||
'assistant.place.addToTourHint': '作為停靠點加入 {name}',
|
||||
'assistant.place.openNow': '營業中',
|
||||
'assistant.place.closed': '已打烊',
|
||||
'assistant.place.fromPrev': '距上一站 {distance}',
|
||||
@@ -388,6 +392,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': '連結打包清單',
|
||||
'assistant.change.verb.unlinkPacking': '取消連結打包清單',
|
||||
'assistant.change.verb.importTour': '從檔案新增路線',
|
||||
'assistant.change.verb.addTourStop': '為路線新增停靠點',
|
||||
'assistant.change.verb.removeTourStop': '從路線中移除停靠點',
|
||||
'assistant.change.label.field': '值',
|
||||
'assistant.change.label.difficulty': '步道最高難度',
|
||||
'assistant.change.label.track': '記錄軌跡',
|
||||
|
||||
@@ -111,6 +111,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.step.costs': '正在检查费用',
|
||||
'assistant.step.help': '正在帮助中查找',
|
||||
'assistant.step.readingBooking': '正在读取预订',
|
||||
'assistant.step.readingTourFile': '正在读取路线文件',
|
||||
'assistant.step.planningHike': '正在规划徒步路线',
|
||||
'assistant.step.planningHikeTo': '正在规划前往{place}的徒步路线',
|
||||
'assistant.step.expenses': '正在读取支出',
|
||||
@@ -205,6 +206,7 @@ const assistant: TranslationStrings = {
|
||||
'assistant.error.maxSteps': '一次处理的内容太多了。要我继续吗?',
|
||||
'assistant.error.offline': '已离线。助手需要联网。',
|
||||
'assistant.error.noBookingsFound': '此文件中未找到预订。',
|
||||
'assistant.error.noTracksFound': '此文件中没有找到轨迹或路线。',
|
||||
'assistant.error.importUnavailable': '此处无法读取预订文件。',
|
||||
'assistant.error.generic': '出了点问题。',
|
||||
'assistant.errorAction.retry': '重试',
|
||||
@@ -268,6 +270,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.place.suggested': '已建议',
|
||||
'assistant.place.inTrip': '已在行程中',
|
||||
'assistant.place.showOnMap': '在地图上',
|
||||
'assistant.place.addToTour': '加入路线',
|
||||
'assistant.place.addToTourHint': '作为停靠点加入 {name}',
|
||||
'assistant.place.openNow': '营业中',
|
||||
'assistant.place.closed': '已打烊',
|
||||
'assistant.place.fromPrev': '距上一站 {distance}',
|
||||
@@ -388,6 +392,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.linkPacking': '关联行李清单',
|
||||
'assistant.change.verb.unlinkPacking': '取消关联行李清单',
|
||||
'assistant.change.verb.importTour': '从文件添加路线',
|
||||
'assistant.change.verb.addTourStop': '为路线添加停靠点',
|
||||
'assistant.change.verb.removeTourStop': '从路线中移除停靠点',
|
||||
'assistant.change.label.field': '值',
|
||||
'assistant.change.label.difficulty': '步道最高难度',
|
||||
'assistant.change.label.track': '记录轨迹',
|
||||
|
||||
@@ -96,6 +96,8 @@ export * from './tours/paddle-check.schema';
|
||||
export * from './tours/paddle-check-basis';
|
||||
export * from './tours/paddle-check';
|
||||
export * from './tours/route-corridor';
|
||||
export * from './tours/poi-insertion';
|
||||
export * from './tours/tour-stop.schema';
|
||||
export * from './tours/horse-route.schema';
|
||||
export * from './wanderer/wanderer.schema';
|
||||
export * from './wanderer/wanderer-watch.schema';
|
||||
|
||||
+38
-17
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import {
|
||||
alongRouteM,
|
||||
chosenAfterId,
|
||||
defaultAfterId,
|
||||
insertionBlock,
|
||||
@@ -9,11 +8,15 @@ import {
|
||||
placesInTour,
|
||||
straightDetourM,
|
||||
withPointAfter,
|
||||
} from './poiInsertion';
|
||||
withPointAppended,
|
||||
type InsertionPoint,
|
||||
} from './poi-insertion';
|
||||
|
||||
// FE-TOUR-POI-INSERT-001 to FE-TOUR-POI-INSERT-009
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const point = (id: string, lat: number, lng: number, over: Partial<TourPlannerWaypoint> = {}): TourPlannerWaypoint => ({
|
||||
// TOUR-POI-INSERT-001 to TOUR-POI-INSERT-012, moved here from the planner with the module.
|
||||
|
||||
const point = (id: string, lat: number, lng: number, over: Partial<InsertionPoint> = {}): InsertionPoint => ({
|
||||
id,
|
||||
lat,
|
||||
lng,
|
||||
@@ -27,14 +30,14 @@ const C = point('c', 47.02, 11.0, { role: 'end' });
|
||||
const NEW = point('new', 47.005, 11.001);
|
||||
|
||||
describe('placing a found place in the tour (PLN-03)', () => {
|
||||
it('FE-TOUR-POI-INSERT-001: goes in right after the point chosen, as a stop the router plans to', () => {
|
||||
it('TOUR-POI-INSERT-001: goes in right after the point chosen, as a stop the router plans to', () => {
|
||||
expect(withPointAfter([A, B, C], 'a', NEW)?.map((p) => p.id)).toEqual(['a', 'new', 'b', 'c']);
|
||||
expect(withPointAfter([A, B, C], 'a', NEW)?.[1]).toMatchObject({ role: 'via' });
|
||||
// After the last point the tour goes on to it.
|
||||
expect(withPointAfter([A, B, C], 'c', NEW)?.map((p) => p.id)).toEqual(['a', 'b', 'c', 'new']);
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-INSERT-002: never cuts a fixed line or a stretch drawn by hand', () => {
|
||||
it('TOUR-POI-INSERT-002: never cuts a fixed line or a stretch drawn by hand', () => {
|
||||
const ferrata = point('b', 47.01, 11.0, {
|
||||
fixedLeg: {
|
||||
kind: 'ferrata',
|
||||
@@ -61,14 +64,14 @@ describe('placing a found place in the tour (PLN-03)', () => {
|
||||
expect(insertionBlock([A, free, C], 'a')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-INSERT-003: nothing goes after the way back of a loop, or after a point that is gone', () => {
|
||||
it('TOUR-POI-INSERT-003: nothing goes after the way back of a loop, or after a point that is gone', () => {
|
||||
const back = point('back', 47.0, 11.0, { role: 'end' });
|
||||
expect(insertionBlock([A, B, back], 'back')).toBe('none');
|
||||
expect(insertionBlock([A, B, C], 'gone')).toBe('none');
|
||||
expect(withPointAfter([A, B, back], 'back', NEW)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-INSERT-004: suggests the last point before the place along the route', () => {
|
||||
it('TOUR-POI-INSERT-004: suggests the last point before the place along the route', () => {
|
||||
const route: Array<[number, number]> = [
|
||||
[47.0, 11.0],
|
||||
[47.01, 11.0],
|
||||
@@ -81,7 +84,7 @@ describe('placing a found place in the tour (PLN-03)', () => {
|
||||
expect(defaultAfterId([A, B, C], null, 1600)).toBe('b');
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-INSERT-005: skips a point that cannot take a stop, and has nothing to suggest without one', () => {
|
||||
it('TOUR-POI-INSERT-005: skips a point that cannot take a stop, and has nothing to suggest without one', () => {
|
||||
const free = point('b', 47.01, 11.0, {
|
||||
fixedLeg: {
|
||||
kind: 'free',
|
||||
@@ -99,7 +102,7 @@ describe('placing a found place in the tour (PLN-03)', () => {
|
||||
describe('the slots a found place can go into (PLN-04)', () => {
|
||||
const back = point('back', 47.0, 11.0, { role: 'end' });
|
||||
|
||||
it('FE-TOUR-POI-INSERT-006: one slot per point, between it and the next, the last one as the new end', () => {
|
||||
it('TOUR-POI-INSERT-006: one slot per point, between it and the next, the last one as the new end', () => {
|
||||
expect(insertionSlots([A, B, C])).toEqual([
|
||||
{ afterId: 'a', beforeId: 'b', block: null },
|
||||
{ afterId: 'b', beforeId: 'c', block: null },
|
||||
@@ -109,7 +112,7 @@ describe('the slots a found place can go into (PLN-04)', () => {
|
||||
expect(insertionSlots([A, B, back])[2]).toEqual({ afterId: 'back', beforeId: null, block: 'none' });
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-INSERT-007: the preview runs through the place to the next point, or ends on it as the new end', () => {
|
||||
it('TOUR-POI-INSERT-007: the preview runs through the place to the next point, or ends on it as the new end', () => {
|
||||
const place = { lat: 47.03, lng: 11.0 };
|
||||
expect(insertionPreview([A, B, C], 'b', place)).toEqual([
|
||||
[47.01, 11.0],
|
||||
@@ -123,7 +126,7 @@ describe('the slots a found place can go into (PLN-04)', () => {
|
||||
expect(insertionPreview([A, B, back], 'back', place)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-INSERT-008: the detour as the crow flies: the way round less the stretch it replaces', () => {
|
||||
it('TOUR-POI-INSERT-008: the detour as the crow flies: the way round less the stretch it replaces', () => {
|
||||
// 1.11 km on to the place and 1.11 km back, instead of the 1.11 km between the points.
|
||||
const between = straightDetourM([
|
||||
[47.01, 11.0],
|
||||
@@ -138,18 +141,18 @@ describe('the slots a found place can go into (PLN-04)', () => {
|
||||
[47.0, 11.0],
|
||||
[47.01, 11.0],
|
||||
[47.02, 11.0],
|
||||
])
|
||||
]),
|
||||
).toBeLessThan(1);
|
||||
// As the new end, the whole way to it.
|
||||
expect(
|
||||
straightDetourM([
|
||||
[47.02, 11.0],
|
||||
[47.03, 11.0],
|
||||
])
|
||||
]),
|
||||
).toBeGreaterThan(1100);
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-INSERT-009: keeps the point picked for the place while it is there, the suggestion otherwise', () => {
|
||||
it('TOUR-POI-INSERT-009: keeps the point picked for the place while it is there, the suggestion otherwise', () => {
|
||||
const place = { id: 'node:1', along_m: 1600 };
|
||||
expect(chosenAfterId([A, B, C], null, place, { poiId: 'node:1', afterId: 'c' })).toBe('c');
|
||||
// Picked for another place, or a point undone since.
|
||||
@@ -158,7 +161,7 @@ describe('the slots a found place can go into (PLN-04)', () => {
|
||||
expect(chosenAfterId([A, B, C], null, place, null)).toBe('b');
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-INSERT-010: tells the places that are a point of the tour already by their spot', () => {
|
||||
it('TOUR-POI-INSERT-010: tells the places that are a point of the tour already by their spot', () => {
|
||||
const places = [
|
||||
{ id: 'node:1', lat: B.lat, lng: B.lng },
|
||||
{ id: 'node:2', lat: B.lat + 0.001, lng: B.lng },
|
||||
@@ -167,3 +170,21 @@ describe('the slots a found place can go into (PLN-04)', () => {
|
||||
expect(placesInTour(places, [A, C]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a stop the assistant takes in (P6)', () => {
|
||||
it('TOUR-POI-INSERT-011: a new end goes after the last point, or before the way back of a loop', () => {
|
||||
const back = point('back', 47.0, 11.0, { role: 'end' });
|
||||
expect(withPointAppended([A, B, C], NEW).map((p) => p.id)).toEqual(['a', 'b', 'c', 'new']);
|
||||
expect(withPointAppended([A, B, C], NEW)[3]).toMatchObject({ role: 'end' });
|
||||
expect(withPointAppended([A, B, back], NEW).map((p) => p.id)).toEqual(['a', 'b', 'new', 'back']);
|
||||
expect(withPointAppended([A, B, back], NEW)[2]).toMatchObject({ role: 'via' });
|
||||
});
|
||||
|
||||
it('TOUR-POI-INSERT-012: never suggests a shaping point, and reads how far along the route a place lies', () => {
|
||||
const shaping = point('s', 47.01, 11.0, { shaping: true });
|
||||
expect(defaultAfterId([A, shaping, C], null, 1600)).toBe('a');
|
||||
const along = alongRouteM([A, B, C], null, { lat: 47.015, lng: 11.0 });
|
||||
expect(along).toBeGreaterThan(1600);
|
||||
expect(along).toBeLessThan(1700);
|
||||
});
|
||||
});
|
||||
+60
-20
@@ -1,7 +1,23 @@
|
||||
import { haversineMetres, isClosedLoop, nearnessToLine } from '@trek/shared';
|
||||
import { withPointAppended, type RoutePoint } from '../planner/tourRouteActions';
|
||||
import { haversineMetres } from '../geo/haversine';
|
||||
import { nearnessToLine } from './route-corridor';
|
||||
import { isClosedLoop } from './tour-walk';
|
||||
import type { TourFixedLeg } from './tours.schema';
|
||||
|
||||
type Point = RoutePoint & { role: string; shaping?: boolean };
|
||||
/**
|
||||
* PLN-03 and PLN-04: where a place found along a tour can go in as a stop,
|
||||
* the same answer for the planner in the browser and for the assistant's
|
||||
* insert_tour_stop on the server. Plain list operations on the ordered points
|
||||
* of a tour; neither side's own point type is needed, only its id, spot,
|
||||
* role and the fixed line its leg to the next point keeps.
|
||||
*/
|
||||
export interface InsertionPoint {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
role: string;
|
||||
fixedLeg?: TourFixedLeg | null;
|
||||
shaping?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a place cannot go in after a point (PLN-03): the leg after it is a
|
||||
@@ -11,8 +27,21 @@ type Point = RoutePoint & { role: string; shaping?: boolean };
|
||||
*/
|
||||
export type InsertionBlock = 'fixed' | 'free' | 'none';
|
||||
|
||||
/**
|
||||
* The points with a new end: after the last point, or for a loop between its
|
||||
* last stop and the way back to the start, so the tour still comes back.
|
||||
*/
|
||||
export function withPointAppended<T extends { lat: number; lng: number; role: string }>(
|
||||
points: readonly T[],
|
||||
point: T,
|
||||
): T[] {
|
||||
if (!isClosedLoop(points)) return [...points, { ...point, role: 'end' }];
|
||||
const end = points[points.length - 1];
|
||||
return end ? [...points.slice(0, -1), { ...point, role: 'via' }, end] : [{ ...point, role: 'end' }];
|
||||
}
|
||||
|
||||
/** What would stop a place going in after `afterId`, or null when it can. */
|
||||
export function insertionBlock(points: readonly Point[], afterId: string): InsertionBlock | null {
|
||||
export function insertionBlock(points: readonly InsertionPoint[], afterId: string): InsertionBlock | null {
|
||||
const index = points.findIndex((point) => point.id === afterId);
|
||||
if (index < 0) return 'none';
|
||||
const last = index === points.length - 1;
|
||||
@@ -29,7 +58,7 @@ export function insertionBlock(points: readonly Point[], afterId: string): Inser
|
||||
* between that point and the next, or as the new end after the last. Null
|
||||
* where {@link insertionBlock} says it cannot go; nothing else is changed.
|
||||
*/
|
||||
export function withPointAfter<T extends Point>(points: readonly T[], afterId: string, point: T): T[] | null {
|
||||
export function withPointAfter<T extends InsertionPoint>(points: readonly T[], afterId: string, point: T): T[] | null {
|
||||
if (insertionBlock(points, afterId) !== null) return null;
|
||||
const index = points.findIndex((candidate) => candidate.id === afterId);
|
||||
if (index === points.length - 1) return withPointAppended(points, point);
|
||||
@@ -39,14 +68,15 @@ export function withPointAfter<T extends Point>(points: readonly T[], afterId: s
|
||||
/**
|
||||
* The point a place found at `alongM` metres along the route most likely
|
||||
* follows: the last point before it along the line, where a stop can go in.
|
||||
* The first point that can take one when none lies before it.
|
||||
* The first point that can take one when none lies before it. A shaping point
|
||||
* only steers the line, so a stop never goes in after one.
|
||||
*/
|
||||
export function defaultAfterId(
|
||||
points: readonly Point[],
|
||||
route: Array<[number, number]> | null,
|
||||
alongM: number
|
||||
points: readonly InsertionPoint[],
|
||||
route: ReadonlyArray<readonly [number, number, ...number[]]> | null,
|
||||
alongM: number,
|
||||
): string | null {
|
||||
const open = points.filter((point) => insertionBlock(points, point.id) === null);
|
||||
const open = points.filter((point) => point.shaping !== true && insertionBlock(points, point.id) === null);
|
||||
if (!open.length) return null;
|
||||
const line = route && route.length > 1 ? route : points.map((point): [number, number] => [point.lat, point.lng]);
|
||||
let chosen = open[0]?.id ?? null;
|
||||
@@ -57,15 +87,25 @@ export function defaultAfterId(
|
||||
return chosen;
|
||||
}
|
||||
|
||||
/** How far along the route a place lies, in metres, read from the line or the points joined straight. */
|
||||
export function alongRouteM(
|
||||
points: readonly InsertionPoint[],
|
||||
route: ReadonlyArray<readonly [number, number, ...number[]]> | null,
|
||||
place: { lat: number; lng: number },
|
||||
): number {
|
||||
const line = route && route.length > 1 ? route : points.map((point): [number, number] => [point.lat, point.lng]);
|
||||
return nearnessToLine(place, line)?.alongM ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The point a place goes in after: the one picked for it while that point is
|
||||
* still there (not undone, not removed), else the suggestion.
|
||||
*/
|
||||
export function chosenAfterId(
|
||||
points: readonly Point[],
|
||||
route: Array<[number, number]> | null,
|
||||
points: readonly InsertionPoint[],
|
||||
route: ReadonlyArray<readonly [number, number, ...number[]]> | null,
|
||||
place: { id: string; along_m: number },
|
||||
picked: { poiId: string; afterId: string } | null
|
||||
picked: { poiId: string; afterId: string } | null,
|
||||
): string | null {
|
||||
if (picked?.poiId === place.id && points.some((point) => point.id === picked.afterId)) return picked.afterId;
|
||||
return defaultAfterId(points, route, place.along_m);
|
||||
@@ -83,7 +123,7 @@ export interface InsertionSlot {
|
||||
block: InsertionBlock | null;
|
||||
}
|
||||
|
||||
export function insertionSlots(points: readonly Point[]): InsertionSlot[] {
|
||||
export function insertionSlots(points: readonly InsertionPoint[]): InsertionSlot[] {
|
||||
return points.map((point, index) => ({
|
||||
afterId: point.id,
|
||||
beforeId: points[index + 1]?.id ?? null,
|
||||
@@ -97,9 +137,9 @@ export function insertionSlots(points: readonly Point[]): InsertionSlot[] {
|
||||
* end. Null where it cannot go there.
|
||||
*/
|
||||
export function insertionPreview(
|
||||
points: readonly Point[],
|
||||
points: readonly InsertionPoint[],
|
||||
afterId: string,
|
||||
place: { lat: number; lng: number }
|
||||
place: { lat: number; lng: number },
|
||||
): Array<[number, number]> | null {
|
||||
if (insertionBlock(points, afterId) !== null) return null;
|
||||
const index = points.findIndex((point) => point.id === afterId);
|
||||
@@ -114,8 +154,8 @@ export function insertionPreview(
|
||||
}
|
||||
|
||||
/** How much longer the preview's lines make the tour, as the crow flies; the route planned after says it exactly. */
|
||||
export function straightDetourM(line: ReadonlyArray<[number, number]>): number {
|
||||
const legs = (path: ReadonlyArray<[number, number]>) =>
|
||||
export function straightDetourM(line: ReadonlyArray<readonly [number, number]>): number {
|
||||
const legs = (path: ReadonlyArray<readonly [number, number]>) =>
|
||||
path.slice(1).reduce((sum, [lat, lng], index) => {
|
||||
const [fromLat, fromLng] = path[index] ?? [lat, lng];
|
||||
return sum + haversineMetres(fromLat, fromLng, lat, lng);
|
||||
@@ -129,10 +169,10 @@ export function straightDetourM(line: ReadonlyArray<[number, number]>): number {
|
||||
/** The places of a search that are a point of the tour already, told by where they lie: a taken place keeps its spot. */
|
||||
export function placesInTour(
|
||||
places: ReadonlyArray<{ id: string; lat: number; lng: number }>,
|
||||
points: readonly RoutePoint[]
|
||||
points: ReadonlyArray<{ lat: number; lng: number }>,
|
||||
): Set<string> {
|
||||
const spots = new Set(points.map((point) => `${point.lat.toFixed(6)},${point.lng.toFixed(6)}`));
|
||||
return new Set(
|
||||
places.filter((place) => spots.has(`${place.lat.toFixed(6)},${place.lng.toFixed(6)}`)).map((place) => place.id)
|
||||
places.filter((place) => spots.has(`${place.lat.toFixed(6)},${place.lng.toFixed(6)}`)).map((place) => place.id),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
tourStopInsertRequestSchema,
|
||||
tourStopInsertResultSchema,
|
||||
tourStopRemoveRequestSchema,
|
||||
tourStopRemoveResultSchema,
|
||||
} from './tour-stop.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const stop = { lat: 47.6, lng: 11.3, name: 'Herzogstandhaus', poi_category: 'shelter' };
|
||||
const tour = {
|
||||
place_id: 4,
|
||||
name: 'Herzogstand',
|
||||
tour_type: 'hike',
|
||||
revision: 3,
|
||||
distance_km: 9.1,
|
||||
minutes: 210,
|
||||
route_pending: false,
|
||||
};
|
||||
|
||||
describe('a stop taken into a saved tour (P6)', () => {
|
||||
it('TOUR-STOP-SCHEMA-001: takes a stop against the revision, after a key or where it lies along the way', () => {
|
||||
expect(tourStopInsertRequestSchema.safeParse({ placeId: 4, expected_revision: 2, stop }).success).toBe(true);
|
||||
const after = { placeId: 4, expected_revision: 2, stop, after_key: 'abc123' };
|
||||
expect(tourStopInsertRequestSchema.safeParse(after).success).toBe(true);
|
||||
expect(tourStopInsertRequestSchema.safeParse({ placeId: 4, stop }).success).toBe(false);
|
||||
expect(tourStopInsertRequestSchema.safeParse({ ...after, after_key: 'no spaces' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('TOUR-STOP-SCHEMA-002: refuses a stop off the globe and fields it does not know', () => {
|
||||
const off = { placeId: 4, expected_revision: 2, stop: { ...stop, lat: 91 } };
|
||||
expect(tourStopInsertRequestSchema.safeParse(off).success).toBe(false);
|
||||
const extra = { placeId: 4, expected_revision: 2, stop: { ...stop, stay: 5 } };
|
||||
expect(tourStopInsertRequestSchema.safeParse(extra).success).toBe(false);
|
||||
expect(tourStopInsertRequestSchema.safeParse({ placeId: 4, expected_revision: 2, stop, x: 1 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('TOUR-STOP-SCHEMA-003: takes a stop out by its key, against the revision', () => {
|
||||
expect(tourStopRemoveRequestSchema.safeParse({ placeId: 4, expected_revision: 3, key: 'abc123' }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(tourStopRemoveRequestSchema.safeParse({ placeId: 4, key: 'abc123' }).success).toBe(false);
|
||||
expect(tourStopRemoveRequestSchema.safeParse({ placeId: 4, expected_revision: 0, key: 'abc123' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('TOUR-STOP-SCHEMA-004: describes what an insert and a removal answer', () => {
|
||||
const point = { key: 'abc123', ...stop };
|
||||
const inserted = {
|
||||
tour,
|
||||
stop: { ...point, after_key: 'start1', after_name: 'Parking' },
|
||||
before: { distance_km: 8.2, minutes: 190 },
|
||||
};
|
||||
expect(tourStopInsertResultSchema.safeParse(inserted).success).toBe(true);
|
||||
expect(tourStopRemoveResultSchema.safeParse({ tour, removed: { ...point, after_key: null } }).success).toBe(true);
|
||||
expect(tourStopRemoveResultSchema.safeParse({ tour: { ...tour, tour_type: 'bike' }, removed: point }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { idSchema } from '../common/primitives.schema';
|
||||
import { stationKeySchema, tourRevisionSchema, tourWaypointSchema } from './tours.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* P6: a place found along a saved tour (a search result, a place the
|
||||
* traveller names) taken in as one stop, and the same stop taken out again.
|
||||
* The server lays the stop in with the planner's own rule (poi-insertion.ts):
|
||||
* after the point named, or where it most likely belongs along the route, and
|
||||
* never into a leg drawn by hand or a fixed line. A hike plans its way anew
|
||||
* through the points, a city walk saves the stop as a station; both against
|
||||
* the revision the tour was read at.
|
||||
*/
|
||||
|
||||
const { lat, lng, name, poi_category, opening_hours } = tourWaypointSchema.shape;
|
||||
|
||||
const placeId = idSchema.describe('The place id of the tour, as list_tours gives it');
|
||||
const expectedRevision = tourRevisionSchema.describe(
|
||||
'The revision of the tour as last read (get_tour_plan or list_tours); a tour changed since is refused, not overwritten',
|
||||
);
|
||||
|
||||
const tourStopSchema = z
|
||||
.object({
|
||||
lat,
|
||||
lng,
|
||||
name: name.describe('The name of the place the stop is, such as a hut, a café or a viewpoint'),
|
||||
poi_category: poi_category.describe(
|
||||
'What kind of place it is, where it is one: peak, shelter, drinking_water, viewpoint, restaurant, museum',
|
||||
),
|
||||
opening_hours: opening_hours.describe(
|
||||
'On a city walk: the opening hours of the place as OpenStreetMap writes them, where known',
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
export type TourStop = z.infer<typeof tourStopSchema>;
|
||||
|
||||
export const tourStopInsertRequestSchema = z
|
||||
.object({
|
||||
placeId,
|
||||
expected_revision: expectedRevision,
|
||||
stop: tourStopSchema.describe('The place taken in as a stop: where it is and what it is called'),
|
||||
after_key: stationKeySchema
|
||||
.optional()
|
||||
.describe(
|
||||
'The key of the point of the tour the stop comes after, as get_tour_plan gives it; left out, the stop goes after the last point before it along the route',
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
export type TourStopInsertRequest = z.infer<typeof tourStopInsertRequestSchema>;
|
||||
|
||||
export const tourStopRemoveRequestSchema = z
|
||||
.object({
|
||||
placeId,
|
||||
expected_revision: expectedRevision,
|
||||
key: stationKeySchema.describe('The key of the stop to take out, as get_tour_plan or insert_tour_stop gives it'),
|
||||
})
|
||||
.strict();
|
||||
export type TourStopRemoveRequest = z.infer<typeof tourStopRemoveRequestSchema>;
|
||||
|
||||
/** The tour types a stop is taken in or out of over the assistant; any other type in the planner. */
|
||||
export const TOUR_STOP_TYPES = ['hike', 'walk'] as const;
|
||||
|
||||
/** The tour as an insert or a removal of a stop saved it. */
|
||||
const tourStopTourSchema = z.object({
|
||||
place_id: idSchema,
|
||||
name: z.string(),
|
||||
tour_type: z.enum(TOUR_STOP_TYPES),
|
||||
revision: tourRevisionSchema,
|
||||
distance_km: z.number().nullable(),
|
||||
minutes: z.number().nullable(),
|
||||
route_pending: z.boolean(),
|
||||
});
|
||||
|
||||
/** One stop as an answer names it: its key (null where the tour gave it none), its place and its name. */
|
||||
const tourStopPointSchema = z.object({
|
||||
key: stationKeySchema.nullable(),
|
||||
lat: z.number(),
|
||||
lng: z.number(),
|
||||
name: z.string().nullable(),
|
||||
poi_category: z.string().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* What insert_tour_stop answers: the tour as saved, the stop with its key and
|
||||
* the point it came after, and the distance and time before and after as the
|
||||
* tour stored them (null where not known).
|
||||
*/
|
||||
export const tourStopInsertResultSchema = z.object({
|
||||
tour: tourStopTourSchema,
|
||||
stop: tourStopPointSchema.extend({ after_key: stationKeySchema.nullable(), after_name: z.string().nullable() }),
|
||||
before: z.object({ distance_km: z.number().nullable(), minutes: z.number().nullable() }),
|
||||
});
|
||||
export type TourStopInsertResult = z.infer<typeof tourStopInsertResultSchema>;
|
||||
|
||||
/** What remove_tour_stop answers: the tour as saved and the stop it took out, with the point it came after. */
|
||||
export const tourStopRemoveResultSchema = z.object({
|
||||
tour: tourStopTourSchema,
|
||||
removed: tourStopPointSchema.extend({ after_key: stationKeySchema.nullable() }),
|
||||
});
|
||||
export type TourStopRemoveResult = z.infer<typeof tourStopRemoveResultSchema>;
|
||||
@@ -307,6 +307,8 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
|
||||
| `create_hike` | Creates a hike from points the traveller confirmed (usually those of `propose_hike`), through the same creation the planner's save uses: its points in order as start, points on the way and end with their names and kinds of place, the way planned by the same router (the line a proposal of the last 15 minutes showed) and saved whole with the height of every point, its distance, ascent, descent, walking time, hiking limit, preferences and planned time. Where the router does not answer, for the way or for its heights, the hike is saved with its points only (`route_pending`), says so, and TREK plans the way when someone opens it in the planner. Any other reason the proposal names (no Valhalla, no way within the limit, a point off every way, a way too long) refuses the hike with why, and nothing is saved, since the planner could not plan it either. With `day_id` it is put on that day of the trip in the same write, `start_time` kept as the start of that visit, and the trip is told of the tour and the visit once both are saved (`assignment` names the visit; `day_edit` is needed as well). Needs `place_edit` on the trip | `trips:write` |
|
||||
| `update_tour` | Changes a saved hike against the `revision` it was read at: a new name, description or planned time keeps its way; new points, a new hiking limit or new preferences plan the way anew as `propose_hike` does, and so does a hike still waiting for its way. A hike changed since that revision is refused as a conflict before anything is planned, and a new way that cannot be planned, or not with its heights, is refused with the reason, leaving the hike as it was. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) or split into stages takes new points only in the planner, the hand-drawn one a new limit or new preferences too. A recorded track without points of its own takes only new points, which plan it over and keep the recording; every other tour type is refused (a city walk changes through `prepare_tour_change` and `save_tour_change`). The hike stays on its days. Needs `place_edit` on the trip | `trips:write` |
|
||||
| `import_tour_gpx` | Takes in the tracks and routes of a GPX file, passed as its text, as tours of the trip, one each, through the same import as the planner's file import: a hike unless another type is named (a bike import with its profile, a run with its profile, a paddle import with its boat and water, a ski import with its discipline). A track already in the trip under the same name and start is skipped and counted; a file without a track or route is refused. Needs `place_edit` on the trip | `trips:write` |
|
||||
| `insert_tour_stop` | Takes a place into a saved hike or city walk as one stop, against the `revision` it was read at: after the point named by its key, or after the last point before it along the route, as the planner puts a place found along a tour. A leg drawn by hand or a fixed line is never cut, and nothing goes after the way back of a loop. A hike plans its way anew through the points as `update_tour` does, keeping its stages and the links of its points; a city walk saves the stop as a new station as `save_tour_change` does. Any other type, and a recorded track without points of its own, is refused. Writes; needs `place_edit` on the trip | `trips:write` |
|
||||
| `remove_tour_stop` | Takes one stop out of a saved hike or city walk by its key, against the `revision` it was read at: the hike is planned anew through the points left, a city walk removes the station unless it is protected or was visited or skipped. The start, the way back of a loop and a tour of two points keep their points. Writes; needs `place_edit` on the trip | `trips:write` |
|
||||
| `prepare_tour_change` | Prepares a change to a saved city walk for the traveller to review, without saving anything: no tour, station, visit or day plan changes, and turning it down keeps the plan as it was. The changes apply in order and name the saved stations by their keys: `add` (a new station after a station; a spontaneous stop on the way is an add after the station the traveller is at), `remove`, `replace` and `move`. A protected station, or one visited or skipped on any day of the walk, is never removed, replaced or moved, nothing is put before the last station already done, and a leg drawn by hand stays as drawn, the closing leg of a loop included; such a change is refused, and with it the whole proposal, naming every change and why. Another type of tour, or a walk that is a recorded track without stations of its own, is refused as well: so far only a city walk takes prepared changes. The answer names the tour and the `revision` it was prepared on (a save has to name it, and a tour changed since is refused rather than overwritten), the stations after the change with what changed each and where it stood before, what was removed, the way on foot before and after planned by the same router TREK uses for city walks and its change in metres and minutes (the distance and walking time stored with the walk given apart), the stays as far as known before and after, every leg after the change with the new ones marked, `data_gaps` for what could not be worked out (no way from the router, stays not known, no arrival times, shaping points dropped because the leg they steered changed), a warning for a station of importance must removed or replaced, each new station that is not yet a place of the trip as `create_place` takes it to keep it as a place instead (a station linked to a place of the trip is left out), whether the traveller may change the trip's places at all (`can_save`) and the `proposal_id` a save of it names | `trips:read` |
|
||||
| `save_tour_change` | Saves a change `prepare_tour_change` prepared, once the traveller said yes to it exactly as shown: the same changes, the `revision` it was prepared on, its `proposal_id` and `confirmed` set to true. Anything else is refused and nothing is saved. The trip permission and the tour are checked again before anything is written: a tour changed since the proposal was prepared is refused as a conflict rather than overwritten (prepare the change again), a proposal already saved, sent again because its answer was lost, writes nothing and answers `already_saved` with the step it was saved as, and a change the rules no longer allow, a station visited or skipped meanwhile or a place of the trip gone, is refused with every reason. The change is saved whole or not at all, through the same write the planner's save uses: the stations in their order, the stations kept keep their keys and their visits, and the way on foot is planned by the same router as the proposal; where it gives no way the walk keeps its stations only (`route_pending`) until someone opens it in the planner. Every save is kept as a step of the tour with the revision it was prepared on and the one it raised the tour to, the changes and the tour as it was before them, so it can be followed and taken back with `undo_tour_change`; nothing of the conversation is kept. A new walk has no revision yet and is made with `create_city_walk`. Needs `place_edit` on the trip | `trips:write` |
|
||||
| `list_tour_change_steps` | Lists the changes saved to a tour with `save_tour_change`, the latest first: each step with when it was saved, the revision it was prepared on and the one it raised the tour to, and the changes as confirmed. `standing` says whether it can be taken back: `latest` (the tour's latest change), `undone` (taken back already, with when and the revision that raised the tour to), `later_change` (something was saved on top of it since, so taking it back would lose that) or `no_earlier_state` (its record of the tour before cannot be read). Edits saved in the planner are no steps, and a draft in the planner is never seen | `trips:read` |
|
||||
|
||||
Reference in New Issue
Block a user