mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
feat(tours): a city walk from a Google Maps walking route, its short link followed only to Google Maps (CW-06, CW-07)
"Add tour" in the planner now offers "Import walk from Google Maps". The dialog takes the link of a route shared in Google Maps, either a maps.app.goo.gl short link or the long google.com/maps/dir link, or the share text of the Google Maps app with the one link in it. It shows the stops in their order with their names, marks a stop the link gives no position as such, says that the route comes from Google Maps with a link to open it there, and that TREK plans the way between the stops itself and does not take the line drawn in Google Maps over. Nothing is written until "Load as walk": that opens a new, unsaved city walk in the planner with the positioned stops and their names, routed on foot by TREK, and warns first when it replaces unsaved work. A stop without a position is left out and never looked up or guessed; with fewer than two positions nothing can be loaded. While the walk is not saved, a calm notice in the planner says where it came from, also after a reload of the draft. Cancelling or a failure leaves every tour as it was. Only a route the link marks unambiguously as on foot is read. A car, bike or transit route, a link that does not say, or one that says two things is refused with a reason the dialog gives in the reader's language. Each stop takes its position from its own block of the link's data, so a line dragged through extra points or a stop given as coordinates no longer leaves the stops without positions or shifts them. The road trip and places imports read links as before. A short link is followed by a resolver of its own: every hop is checked against the Google Maps short link shapes and the strict SSRF guard before it is looked up or asked, so ALLOW_INTERNAL_NETWORK and ALLOW_LINK_LOCAL_IPS do not open it. Each request goes to the vetted addresses only, follows nothing by itself, reads no body and carries no credentials; the chain has one deadline including name lookups, at most three hops and a cap on the Location header, and the long link it ends on must be Google Maps and is never fetched. The preview is POST /api/trips/:tripId/tours/import/google-maps/preview for members who may edit places, with a 502 when Google could not be reached, and the MCP tool preview_google_maps_walk reads the same.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import type { WalkImportPreviewRequest, WalkImportPreviewResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* CW-07: a Google Maps walking route read into its stops. The server may
|
||||
* follow a short link a few hops within its own deadline, so the call waits
|
||||
* a little longer than that before it gives up.
|
||||
*/
|
||||
const WALK_IMPORT_TIMEOUT_MS = 20_000;
|
||||
|
||||
export const tourWalkImportApi = {
|
||||
preview: (
|
||||
tripId: number | string,
|
||||
body: WalkImportPreviewRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<WalkImportPreviewResponse> =>
|
||||
apiClient
|
||||
.post(`/trips/${tripId}/tours/import/google-maps/preview`, body, { signal, timeout: WALK_IMPORT_TIMEOUT_MS })
|
||||
.then((r) => r.data),
|
||||
};
|
||||
@@ -11,8 +11,20 @@ vi.mock('../library/useTourLibrary', () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock('../library/TourLibraryDialog', () => ({ TourLibraryDialog: () => <div data-testid="library-dialog" /> }));
|
||||
const walk = vi.hoisted(() => ({ show: vi.fn(), options: null as unknown }));
|
||||
vi.mock('../walk/useWalkImport', () => ({
|
||||
useWalkImport: (options: unknown) => {
|
||||
walk.options = options;
|
||||
return { show: walk.show };
|
||||
},
|
||||
}));
|
||||
vi.mock('../walk/WalkImportDialog', () => ({
|
||||
WalkImportDialog: ({ hasUnsavedChanges }: { hasUnsavedChanges: boolean }) => (
|
||||
<div data-testid="walk-dialog" data-unsaved={String(hasUnsavedChanges)} />
|
||||
),
|
||||
}));
|
||||
|
||||
// FE-TOUR-ADD-001 to FE-TOUR-ADD-005
|
||||
// FE-TOUR-ADD-001 to FE-TOUR-ADD-006
|
||||
|
||||
function planner(mode: 'neutral' | 'new-draft' = 'new-draft', overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -21,6 +33,7 @@ function planner(mode: 'neutral' | 'new-draft' = 'new-draft', overrides: Record<
|
||||
hasUnsavedChanges: false,
|
||||
openTour: vi.fn(),
|
||||
startNewTour: vi.fn(),
|
||||
startDerivedTour: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as Parameters<typeof TourAddMenu>[0]['planner'];
|
||||
}
|
||||
@@ -32,7 +45,7 @@ const open = () => fireEvent.click(screen.getByTestId('tour-rail-add'));
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('TourAddMenu (LIB-02)', () => {
|
||||
it('FE-TOUR-ADD-001: one button opens the three ways to add a tour, in order', () => {
|
||||
it('FE-TOUR-ADD-001: one button opens the four ways to add a tour, in order', () => {
|
||||
render(<TourAddMenu planner={planner()} gpxImport={gpxImport()} onTakenOver={vi.fn()} disabled={false} />);
|
||||
const button = screen.getByTestId('tour-rail-add');
|
||||
expect(button).toHaveAccessibleName('Add tour');
|
||||
@@ -42,6 +55,7 @@ describe('TourAddMenu (LIB-02)', () => {
|
||||
expect(entries().map((entry) => entry.textContent)).toEqual([
|
||||
'Plan new tour',
|
||||
'Import GPX',
|
||||
'Import walk from Google Maps',
|
||||
'Take over a tour from another trip',
|
||||
]);
|
||||
});
|
||||
@@ -65,7 +79,11 @@ describe('TourAddMenu (LIB-02)', () => {
|
||||
it('FE-TOUR-ADD-003: with no tour open the map is already planning one, so that entry is left out', () => {
|
||||
render(<TourAddMenu planner={planner('neutral')} gpxImport={gpxImport()} onTakenOver={vi.fn()} disabled={false} />);
|
||||
open();
|
||||
expect(entries().map((entry) => entry.textContent)).toEqual(['Import GPX', 'Take over a tour from another trip']);
|
||||
expect(entries().map((entry) => entry.textContent)).toEqual([
|
||||
'Import GPX',
|
||||
'Import walk from Google Maps',
|
||||
'Take over a tour from another trip',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-ADD-004: a tour taken over comes in, and opens unless the editor holds unsaved work', async () => {
|
||||
@@ -84,13 +102,25 @@ describe('TourAddMenu (LIB-02)', () => {
|
||||
expect(screen.getByTestId('library-dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-ADD-005: waits while saving or importing, and is not there with nothing to offer', () => {
|
||||
it('FE-TOUR-ADD-005: waits while saving or importing, and offers a walk from Google Maps with nothing else to offer', () => {
|
||||
const { rerender } = render(<TourAddMenu planner={planner()} gpxImport={gpxImport(true)} disabled={false} />);
|
||||
expect(screen.getByTestId('tour-rail-add')).toBeDisabled();
|
||||
rerender(<TourAddMenu planner={planner()} gpxImport={gpxImport()} disabled />);
|
||||
expect(screen.getByTestId('tour-rail-add')).toBeDisabled();
|
||||
rerender(<TourAddMenu planner={planner('neutral')} disabled={false} />);
|
||||
expect(screen.queryByTestId('tour-rail-add')).toBeNull();
|
||||
open();
|
||||
expect(entries().map((entry) => entry.textContent)).toEqual(['Import walk from Google Maps']);
|
||||
expect(screen.queryByTestId('library-dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-ADD-006: the walk entry opens its dialog, which knows about unsaved work in the planner (CW-07)', () => {
|
||||
const p = planner('new-draft', { hasUnsavedChanges: true });
|
||||
render(<TourAddMenu planner={p} gpxImport={gpxImport()} disabled={false} />);
|
||||
expect((walk.options as { planner: unknown }).planner).toBe(p);
|
||||
expect(screen.getByTestId('walk-dialog')).toHaveAttribute('data-unsaved', 'true');
|
||||
open();
|
||||
fireEvent.click(screen.getByText('Import walk from Google Maps'));
|
||||
expect(walk.show).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { FileDown, Library, Loader2, PenLine, Plus } from 'lucide-react';
|
||||
import { FileDown, Footprints, Library, Loader2, PenLine, Plus } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { PopoverItem } from '../../Packing/PackingPopover';
|
||||
@@ -9,6 +9,8 @@ import { usePopoverList } from '../../shared/usePopoverList';
|
||||
import { TourLibraryDialog } from '../library/TourLibraryDialog';
|
||||
import { useTourLibrary } from '../library/useTourLibrary';
|
||||
import type { TourGpxImport } from '../useTourGpxImport';
|
||||
import { WalkImportDialog } from '../walk/WalkImportDialog';
|
||||
import { useWalkImport } from '../walk/useWalkImport';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
interface AddEntry {
|
||||
@@ -21,9 +23,12 @@ interface AddEntry {
|
||||
/**
|
||||
* LIB-02: the tours rail's one way to add a tour, as a menu: plan a new one
|
||||
* (while another is open; with none open the map is already planning one),
|
||||
* import GPX files, or take one over from another trip, whose dialog opens
|
||||
* from here. A tour taken over comes into the trip and opens in the editor,
|
||||
* unless the editor holds unsaved work.
|
||||
* import GPX files, read a city walk from a Google Maps walking route (CW-07),
|
||||
* or take one over from another trip. The walk and the take-over open their
|
||||
* dialogs from here. A tour taken over comes into the trip and opens in the
|
||||
* editor, unless the editor holds unsaved work; a walk read from Google Maps
|
||||
* opens in the planner as a new draft, after a warning when that replaces
|
||||
* unsaved work.
|
||||
*/
|
||||
export function TourAddMenu({
|
||||
planner,
|
||||
@@ -31,14 +36,17 @@ export function TourAddMenu({
|
||||
onTakenOver,
|
||||
disabled,
|
||||
}: {
|
||||
planner: Pick<TourPlannerController, 'tripId' | 'mode' | 'hasUnsavedChanges' | 'openTour' | 'startNewTour'>;
|
||||
planner: Pick<
|
||||
TourPlannerController,
|
||||
'tripId' | 'mode' | 'hasUnsavedChanges' | 'openTour' | 'startNewTour' | 'startDerivedTour'
|
||||
>;
|
||||
gpxImport?: TourGpxImport;
|
||||
/** Where a tour of another trip goes into this one; without it that entry is left out. */
|
||||
onTakenOver?: (tour: TourListItem) => Promise<void>;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const list = usePopoverList(0, { estimatedHeight: 140 });
|
||||
const list = usePopoverList(0, { estimatedHeight: 176 });
|
||||
const { open, toggle, close, triggerRef } = list;
|
||||
const library = useTourLibrary({
|
||||
tripId: Number(planner.tripId),
|
||||
@@ -47,6 +55,7 @@ export function TourAddMenu({
|
||||
if (!planner.hasUnsavedChanges) void planner.openTour(tour);
|
||||
},
|
||||
});
|
||||
const walkImport = useWalkImport({ planner });
|
||||
const importing = gpxImport?.importing ?? false;
|
||||
|
||||
const entries: AddEntry[] = [];
|
||||
@@ -66,6 +75,12 @@ export function TourAddMenu({
|
||||
run: gpxImport.pick,
|
||||
});
|
||||
}
|
||||
entries.push({
|
||||
key: 'walk',
|
||||
icon: <Footprints size={14} strokeWidth={2} />,
|
||||
label: t('tours.walkImport.menu'),
|
||||
run: walkImport.show,
|
||||
});
|
||||
if (onTakenOver) {
|
||||
entries.push({
|
||||
key: 'library',
|
||||
@@ -74,7 +89,6 @@ export function TourAddMenu({
|
||||
run: library.show,
|
||||
});
|
||||
}
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
// Run inside the click that picked it: the file picker opens only from a user's own tap.
|
||||
const pick = (entry: AddEntry) => {
|
||||
@@ -106,6 +120,7 @@ export function TourAddMenu({
|
||||
<PopoverItem key={entry.key} icon={entry.icon} label={entry.label} onClick={() => pick(entry)} />
|
||||
))}
|
||||
</PopoverListPanel>
|
||||
<WalkImportDialog walkImport={walkImport} hasUnsavedChanges={planner.hasUnsavedChanges} />
|
||||
{onTakenOver && <TourLibraryDialog library={library} />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -901,6 +901,17 @@ describe('TourPlannerRail', () => {
|
||||
expect(screen.getByLabelText('Unsaved draft restored')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Edit tour' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('TOUR-PLANNER-RAIL-022: an unsaved walk read from Google Maps says so, and a saved one no longer does (CW-07)', () => {
|
||||
const notice = 'From a Google Maps walking route. TREK planned the way between the stops itself.'
|
||||
const { rerender } = render(<TourPlannerRail planner={planner({ draftSource: 'google_maps', hasUnsavedChanges: true, mode: { type: 'new-draft' } })} />)
|
||||
expect(screen.getByText(notice)).toBeInTheDocument()
|
||||
|
||||
rerender(<TourPlannerRail planner={planner({ draftSource: 'google_maps', editingPlaceId: tour.place_id, mode: { type: 'edit-saved', placeId: tour.place_id } })} />)
|
||||
expect(screen.queryByText(notice)).toBeNull()
|
||||
rerender(<TourPlannerRail planner={planner({ hasUnsavedChanges: true, mode: { type: 'new-draft' } })} />)
|
||||
expect(screen.queryByText(notice)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the paddle checklist in the rails (PAD-06)', () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ import { type AssignTourToDay, useTourDayAssignment } from '../useTourDayAssignm
|
||||
import WandererMark from '../wanderer/WandererMark'
|
||||
import { TourGpxExportBarButton } from '../TourGpxExportButton'
|
||||
import { TourRemoteChangeNotice, TourRevisionConflictDialog } from './TourRevisionNotice'
|
||||
import { WalkImportDraftNotice } from '../walk/WalkImportDialog'
|
||||
import TourListFilterMenu, { TourFilterEmpty } from '../TourListFilterMenu'
|
||||
import { TourScopeSummary } from '../TourScopeSummary'
|
||||
import { useTourListFilters } from '../useTourListFilters'
|
||||
@@ -438,6 +439,7 @@ export function TourPlannerRail({ planner,
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 py-3.5">
|
||||
<TourRemoteChangeNotice planner={planner} />
|
||||
<WalkImportDraftNotice planner={planner} />
|
||||
<TourNameField planner={planner} locked={locked} />
|
||||
|
||||
<TourTypeFields planner={planner} locked={locked} />
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
import type { PaddleFieldValues } from './usePaddleFields';
|
||||
|
||||
/** CW-07: where a draft's points were read from outside the trip, said in the planner until it is saved. */
|
||||
export type DraftSource = 'google_maps';
|
||||
|
||||
/**
|
||||
* The tour planner's draft as it is kept in local storage between visits, and
|
||||
* the check a stored draft has to pass before the planner takes it back: a
|
||||
@@ -30,6 +33,8 @@ export interface DraftPayload {
|
||||
editingPlaceId?: number | null;
|
||||
/** The import a new tour is being planned from; carried until it is saved. */
|
||||
derivedFromPlaceId?: number | null;
|
||||
/** CW-07: a draft loaded from a Google Maps walk keeps saying so across a reload. */
|
||||
draftSource?: DraftSource | null;
|
||||
tourType?: PlannableTourType;
|
||||
paddle?: PaddleFieldValues;
|
||||
/** P2C-03: the revision of the tour the draft edits, as it was loaded. */
|
||||
@@ -58,6 +63,10 @@ function optionalPlaceId(value: number | null | undefined): boolean {
|
||||
return value == null || (Number.isInteger(value) && value > 0);
|
||||
}
|
||||
|
||||
function optionalSource(value: unknown): boolean {
|
||||
return value == null || value === 'google_maps';
|
||||
}
|
||||
|
||||
function optionalDifficulty(value: number | null | undefined): boolean {
|
||||
return value == null || (Number.isInteger(value) && value >= 1 && value <= 6);
|
||||
}
|
||||
@@ -97,6 +106,7 @@ export function validDraft(value: unknown): value is DraftPayload {
|
||||
optionalMinutes(draft.breakAdditionalMinutes) &&
|
||||
optionalPlaceId(draft.editingPlaceId) &&
|
||||
optionalPlaceId(draft.derivedFromPlaceId) &&
|
||||
optionalSource(draft.draftSource) &&
|
||||
optionalPlaceId(draft.baseRevision) &&
|
||||
validRouteChoices(draft) &&
|
||||
Array.isArray(draft.waypoints) &&
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useTourDraftStorage } from './useTourDraftStorage'
|
||||
import { useTourRevisionGuard } from './useTourRevisionGuard'
|
||||
import { useRouteShaping } from './useRouteShaping'
|
||||
import { useTourMapView } from './useTourMapView'
|
||||
import type { DraftPayload } from './tourPlannerDraft'
|
||||
import type { DraftPayload, DraftSource } from './tourPlannerDraft'
|
||||
import { paddleEstimateOf } from './paddlePlanOf'
|
||||
import { adoptChangedPoints } from './paddleRouting'
|
||||
import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields'
|
||||
@@ -26,9 +26,10 @@ export interface DerivedTourDraft {
|
||||
description?: string | null
|
||||
website?: string | null
|
||||
difficulty: TourMaxHikingDifficulty
|
||||
points: Array<{ lat: number; lng: number }>
|
||||
/** The import the points were taken from. */
|
||||
sourcePlaceId: number
|
||||
/** The control points in order; a city walk's stop keeps the name it came with (CW-07). */
|
||||
points: Array<{ lat: number; lng: number; name?: string | null }>
|
||||
/** The import the points were taken from; absent for a link read from outside the trip (CW-07). */
|
||||
sourcePlaceId?: number
|
||||
/**
|
||||
* Plan the import over in place, keeping its id, days and notes (the server
|
||||
* keeps the recorded track to go back to), rather than as a new tour beside it.
|
||||
@@ -40,6 +41,8 @@ export interface DerivedTourDraft {
|
||||
tourType?: PlannableTourType
|
||||
paddle?: PaddleFieldValues
|
||||
bikeProfile?: BikeProfile
|
||||
/** CW-07: where the points came from, said in the planner until the draft is saved. */
|
||||
source?: DraftSource
|
||||
}
|
||||
|
||||
export type TourPlannerStatus =
|
||||
@@ -133,6 +136,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const [draftRestored, setDraftRestored] = useState(Boolean(restored))
|
||||
const [editingPlaceId, setEditingPlaceId] = useState<number | null>(restored?.editingPlaceId ?? null)
|
||||
const [derivedFromPlaceId, setDerivedFromPlaceId] = useState<number | null>(restored?.derivedFromPlaceId ?? null)
|
||||
const [draftSource, setDraftSource] = useState<DraftSource | null>(restored?.draftSource ?? null)
|
||||
const [openingTourId, setOpeningTourId] = useState<number | null>(null)
|
||||
const [saveOutcome, setSaveOutcome] = useState<TourListItem | null>(null)
|
||||
const [retryToken, setRetryToken] = useState(0)
|
||||
@@ -540,6 +544,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setEditingPlaceId(null)
|
||||
editingPlaceIdRef.current = null
|
||||
setDerivedFromPlaceId(null)
|
||||
setDraftSource(null)
|
||||
adoptRevision(null)
|
||||
setOpeningTourId(null)
|
||||
setSaveOutcome(null)
|
||||
@@ -587,9 +592,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
returnToNeutral()
|
||||
const type = draft.tourType ?? 'hike'
|
||||
const points = normalizePlannerWaypoints(
|
||||
draft.points.map(({ lat, lng }) => ({ id: randomId(), lat, lng, role: 'via' as const })),
|
||||
draft.points.map(({ lat, lng, name }) => ({
|
||||
id: randomId(), lat, lng, role: 'via' as const, ...(name ? { name } : {}),
|
||||
})),
|
||||
type === 'kayak',
|
||||
)
|
||||
setDraftSource(draft.source ?? null)
|
||||
tourTypeRef.current = type
|
||||
setTourTypeState(type)
|
||||
if (draft.paddle) loadPaddle(draft.paddle)
|
||||
@@ -605,12 +613,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setMaxHikingDifficultyState(draft.difficulty)
|
||||
difficultyRef.current = draft.difficulty
|
||||
setSelectedWaypointId(points[0].id)
|
||||
if (draft.inPlace) {
|
||||
if (draft.inPlace && draft.sourcePlaceId !== undefined) {
|
||||
setEditingPlaceId(draft.sourcePlaceId)
|
||||
editingPlaceIdRef.current = draft.sourcePlaceId
|
||||
adoptRevision(draft.revision)
|
||||
} else {
|
||||
setDerivedFromPlaceId(draft.sourcePlaceId)
|
||||
setDerivedFromPlaceId(draft.sourcePlaceId ?? null)
|
||||
}
|
||||
setStatus('dirty')
|
||||
setHasUnsavedChanges(true)
|
||||
@@ -707,6 +715,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setMapFocusKey(key => key + 1)
|
||||
setSaveOutcome(null)
|
||||
setReadOnlyGpxTour(null)
|
||||
setDraftSource(null)
|
||||
return true
|
||||
} catch {
|
||||
if (requestIsCurrent() && !saveInFlightRef.current) setError('open')
|
||||
@@ -749,12 +758,12 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
const draft: DraftPayload = { version: 10, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
|
||||
derivedFromPlaceId, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile,
|
||||
derivedFromPlaceId, draftSource, tourType, baseRevision: revision.base, hikingPreferences, bikeProfile,
|
||||
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle },
|
||||
}
|
||||
keepDraft(draft)
|
||||
}, [breakAdditionalMinutes,
|
||||
derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, keepDraft, waypoints, website,
|
||||
derivedFromPlaceId, draftSource, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, keepDraft, waypoints, website,
|
||||
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle, revision.base, hikingPreferences,
|
||||
bikeProfile])
|
||||
|
||||
@@ -828,6 +837,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
clearHistory()
|
||||
setEditingPlaceId(result.tour.place_id)
|
||||
editingPlaceIdRef.current = result.tour.place_id
|
||||
setDraftSource(null)
|
||||
setSaveOutcome(result.tour)
|
||||
}
|
||||
if (mountedRef.current && String(tripIdRef.current) === submittedTripId && !deletedTourPlaceIdsRef.current.has(result.tour.place_id)) {
|
||||
@@ -873,6 +883,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
startNewTour,
|
||||
startDerivedTour,
|
||||
derivedFromPlaceId,
|
||||
draftSource,
|
||||
newTourConfirmationOpen, cancelNewTour, forgetDeletedTour,
|
||||
elevationProfileExpanded, toggleElevationProfile, newDraftGeneration, draftRestored, mapFocusKey,
|
||||
editingPlaceId, openingTourId, openTour,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { TourCreateRequest, TourCreateResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { routeWalkingTour, routeCityWalk, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(
|
||||
() => ({
|
||||
routeWalkingTour: vi.fn(),
|
||||
routeCityWalk: vi.fn(),
|
||||
enrichTourElevations: vi.fn(),
|
||||
createTour: vi.fn(),
|
||||
detailTour: vi.fn(),
|
||||
updateTour: vi.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock('./tourRouting', () => ({ routeWalkingTour, routeCityWalk, enrichTourElevations }));
|
||||
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
|
||||
vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) }));
|
||||
|
||||
import { validDraft } from './tourPlannerDraft';
|
||||
import { useTourPlanner, type DerivedTourDraft } from './useTourPlanner';
|
||||
|
||||
// FE-TOUR-WALK-IMPORT-PLANNER-001 to FE-TOUR-WALK-IMPORT-PLANNER-004: a walk read from a Google Maps link,
|
||||
// opened as a new draft with its stop names, routed on foot by TREK and saved like any other walk (CW-07).
|
||||
|
||||
const routed = {
|
||||
coordinates: [
|
||||
[41.1425899, -8.6092477],
|
||||
[41.1382776, -8.6117899],
|
||||
] as [number, number][],
|
||||
parts: [
|
||||
{
|
||||
coordinates: [
|
||||
[41.1425899, -8.6092477],
|
||||
[41.1382776, -8.6117899],
|
||||
] as [number, number][],
|
||||
free: false,
|
||||
},
|
||||
],
|
||||
distanceMeters: 1400,
|
||||
durationSeconds: 1200,
|
||||
};
|
||||
const enriched = [
|
||||
[41.1425899, -8.6092477, 80],
|
||||
[41.1382776, -8.6117899, 10],
|
||||
] as [number, number, number][];
|
||||
|
||||
const answered = (body: TourCreateRequest): TourCreateResponse => ({
|
||||
tour: {
|
||||
place_id: 51,
|
||||
name: body.name,
|
||||
tour_type: 'walk',
|
||||
distance: 1.4,
|
||||
elevation_gain: 5,
|
||||
elevation_loss: 75,
|
||||
duration: 20,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: null,
|
||||
planned: false,
|
||||
caution: false,
|
||||
revision: 1,
|
||||
},
|
||||
waypoints: body.waypoints.map((point, index) => ({ ...point, key: point.key ?? `stop-${index}` })),
|
||||
});
|
||||
|
||||
/** What the walk import hands the planner for a route of three stops, the middle one given as coordinates. */
|
||||
const FROM_GOOGLE: DerivedTourDraft = {
|
||||
name: 'Walk from Google Maps',
|
||||
difficulty: 2,
|
||||
points: [
|
||||
{ lat: 41.1425899, lng: -8.6092477, name: 'Igreja de Santa Clara' },
|
||||
{ lat: 41.1428531, lng: -8.6111165, name: null },
|
||||
{ lat: 41.1382776, lng: -8.6117899, name: 'Ribeira de Gaia' },
|
||||
],
|
||||
inPlace: false,
|
||||
tourType: 'walk',
|
||||
source: 'google_maps',
|
||||
};
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
}
|
||||
|
||||
const storedDraft = (tripId: number) => JSON.parse(localStorage.getItem(`tour-draft-${tripId}`) ?? 'null') as unknown;
|
||||
|
||||
describe('useTourPlanner, a walk read from Google Maps (CW-07)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
routeWalkingTour.mockReset().mockResolvedValue(routed);
|
||||
routeCityWalk.mockReset().mockResolvedValue(routed);
|
||||
enrichTourElevations.mockReset().mockResolvedValue(enriched);
|
||||
createTour.mockReset().mockImplementation(async (_trip: number, body: TourCreateRequest) => answered(body));
|
||||
detailTour.mockReset();
|
||||
updateTour.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('FE-TOUR-WALK-IMPORT-PLANNER-001: opens as a new walk with its stop names, routed on foot by TREK', async () => {
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 31 }));
|
||||
let started = false;
|
||||
act(() => {
|
||||
started = result.current.startDerivedTour(FROM_GOOGLE);
|
||||
});
|
||||
expect(started).toBe(true);
|
||||
expect(result.current.mode).toEqual({ type: 'new-draft' });
|
||||
expect(result.current.tourType).toBe('walk');
|
||||
expect(result.current.name).toBe('Walk from Google Maps');
|
||||
expect(result.current.draftSource).toBe('google_maps');
|
||||
expect(result.current.derivedFromPlaceId).toBeNull();
|
||||
expect(result.current.editingPlaceId).toBeNull();
|
||||
expect(result.current.waypoints.map((point) => [point.name, point.role])).toEqual([
|
||||
['Igreja de Santa Clara', 'start'],
|
||||
[undefined, 'via'],
|
||||
['Ribeira de Gaia', 'end'],
|
||||
]);
|
||||
await settle();
|
||||
expect(routeCityWalk).toHaveBeenCalledTimes(1);
|
||||
expect(routeWalkingTour).not.toHaveBeenCalled();
|
||||
expect(result.current.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WALK-IMPORT-PLANNER-002: saves as a walk with the stop names and no import it was derived from', async () => {
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 31 }));
|
||||
act(() => {
|
||||
result.current.startDerivedTour(FROM_GOOGLE);
|
||||
});
|
||||
await settle();
|
||||
expect(result.current.canSave).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
const body = createTour.mock.calls[0]?.[1] as TourCreateRequest;
|
||||
expect(body.tour_type).toBe('walk');
|
||||
expect(body.waypoints.map((point) => point.name ?? null)).toEqual([
|
||||
'Igreja de Santa Clara',
|
||||
null,
|
||||
'Ribeira de Gaia',
|
||||
]);
|
||||
expect(body).not.toHaveProperty('derived_from_place_id');
|
||||
// Saved, the walk is TREK's own: the notice of where it came from goes.
|
||||
expect(result.current.draftSource).toBeNull();
|
||||
expect(result.current.mode).toEqual({ type: 'edit-saved', placeId: 51 });
|
||||
});
|
||||
|
||||
it('FE-TOUR-WALK-IMPORT-PLANNER-003: the source is kept with the unsaved draft across a reload, and forgotten when it is thrown away', () => {
|
||||
const first = renderHook(() => useTourPlanner({ tripId: 32 }));
|
||||
act(() => {
|
||||
first.result.current.startDerivedTour(FROM_GOOGLE);
|
||||
});
|
||||
expect(storedDraft(32)).toMatchObject({ draftSource: 'google_maps', tourType: 'walk' });
|
||||
first.unmount();
|
||||
|
||||
const reloaded = renderHook(() => useTourPlanner({ tripId: 32 }));
|
||||
expect(reloaded.result.current.draftSource).toBe('google_maps');
|
||||
expect(reloaded.result.current.waypoints[0]?.name).toBe('Igreja de Santa Clara');
|
||||
act(() => reloaded.result.current.returnToNeutral());
|
||||
expect(reloaded.result.current.draftSource).toBeNull();
|
||||
expect(storedDraft(32)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WALK-IMPORT-PLANNER-004: a stored draft names no source, or Google Maps; any other is not taken back', () => {
|
||||
const draft = { version: 10, name: 'Walk', waypoints: [] };
|
||||
expect(validDraft(draft)).toBe(true);
|
||||
expect(validDraft({ ...draft, draftSource: null })).toBe(true);
|
||||
expect(validDraft({ ...draft, draftSource: 'google_maps' })).toBe(true);
|
||||
expect(validDraft({ ...draft, draftSource: 'komoot' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { WalkImportPreviewResponse } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import type { WalkImport } from './useWalkImport';
|
||||
import { WalkImportDialog, WalkImportDraftNotice } from './WalkImportDialog';
|
||||
|
||||
// FE-WALK-IMPORT-DIALOG-001 to FE-WALK-IMPORT-DIALOG-006
|
||||
|
||||
const ROUTE = 'https://www.google.com/maps/dir/Cathedral/Market/Park/data=!3e2';
|
||||
const PREVIEW: WalkImportPreviewResponse = {
|
||||
stations: [
|
||||
{ name: 'Cathedral', lat: 48.1, lng: 11.5 },
|
||||
{ name: 'Market', lat: null, lng: null },
|
||||
{ name: null, lat: 48.12, lng: 11.52 },
|
||||
],
|
||||
resolved_url: ROUTE,
|
||||
};
|
||||
|
||||
function walkImport(over: Partial<WalkImport> = {}): WalkImport {
|
||||
return {
|
||||
open: true,
|
||||
show: vi.fn(),
|
||||
close: vi.fn(),
|
||||
url: '',
|
||||
setUrl: vi.fn(),
|
||||
phase: 'link',
|
||||
preview: null,
|
||||
failure: null,
|
||||
unplaced: 0,
|
||||
canRead: false,
|
||||
canLoad: false,
|
||||
read: vi.fn().mockResolvedValue(undefined),
|
||||
back: vi.fn(),
|
||||
load: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const atPreview = (over: Partial<WalkImport> = {}) =>
|
||||
walkImport({ phase: 'preview', preview: PREVIEW, unplaced: 1, canLoad: true, url: ROUTE, ...over });
|
||||
|
||||
describe('WalkImportDialog (CW-07)', () => {
|
||||
it('FE-WALK-IMPORT-DIALOG-001: asks for the link, reads it on the button and on Enter, and says why one was refused', () => {
|
||||
const closed = walkImport({ open: false });
|
||||
const { rerender } = render(<WalkImportDialog walkImport={closed} hasUnsavedChanges={false} />);
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
|
||||
const w = walkImport({ url: 'https://maps.app.goo.gl/abc', canRead: true });
|
||||
rerender(<WalkImportDialog walkImport={w} hasUnsavedChanges={false} />);
|
||||
expect(screen.getByText('Walk from a Google Maps link')).toBeInTheDocument();
|
||||
const input = screen.getByLabelText('Link to the walking route');
|
||||
fireEvent.change(input, { target: { value: 'https://maps.app.goo.gl/xyz' } });
|
||||
expect(w.setUrl).toHaveBeenCalledWith('https://maps.app.goo.gl/xyz');
|
||||
fireEvent.click(screen.getByTestId('walk-import-read'));
|
||||
expect(w.read).toHaveBeenCalledTimes(1);
|
||||
fireEvent.submit(input.closest('form') as HTMLFormElement);
|
||||
expect(w.read).toHaveBeenCalledTimes(2);
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(w.close).toHaveBeenCalledTimes(1);
|
||||
|
||||
const refused = walkImport({ failure: 'That route is not planned on foot.' });
|
||||
rerender(<WalkImportDialog walkImport={refused} hasUnsavedChanges={false} />);
|
||||
expect(screen.getByText('That route is not planned on foot.')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('walk-import-read')).toBeDisabled();
|
||||
fireEvent.submit(screen.getByLabelText('Link to the walking route').closest('form') as HTMLFormElement);
|
||||
expect(refused.read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-DIALOG-002: waits while the link is read', () => {
|
||||
render(<WalkImportDialog walkImport={walkImport({ phase: 'reading', url: ROUTE })} hasUnsavedChanges={false} />);
|
||||
expect(screen.getByLabelText('Link to the walking route')).toBeDisabled();
|
||||
expect(screen.getByTestId('walk-import-read')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-DIALOG-003: shows the stops in their order, the one without a position marked', () => {
|
||||
render(<WalkImportDialog walkImport={atPreview()} hasUnsavedChanges={false} />);
|
||||
const rows = screen.getAllByTestId('walk-import-station');
|
||||
expect(rows.map((row) => row.textContent)).toEqual([
|
||||
'1Cathedral48.10000, 11.50000',
|
||||
'2MarketNo position in the link',
|
||||
'3Stop 348.12000, 11.52000',
|
||||
]);
|
||||
expect(rows.map((row) => row.getAttribute('data-unresolved'))).toEqual([null, 'true', null]);
|
||||
expect(screen.getByText('1 stop has no position in the link and is left out.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('TREK plans the way between the stops again; the line drawn in Google Maps is not taken over.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('A walk needs at least two stops with a position.')).toBeNull();
|
||||
expect(screen.queryByText('Loading the walk replaces the unsaved changes in the planner.')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-DIALOG-004: names Google Maps as the source and opens the route read there in a new tab', () => {
|
||||
render(<WalkImportDialog walkImport={atPreview()} hasUnsavedChanges={false} />);
|
||||
const preview = screen.getByTestId('walk-import-preview');
|
||||
expect(within(preview).getByText('From Google Maps')).toBeInTheDocument();
|
||||
const source = screen.getByTestId('walk-import-source');
|
||||
expect(source).toHaveAttribute('href', ROUTE);
|
||||
expect(source).toHaveAttribute('target', '_blank');
|
||||
expect(source).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(source).toHaveTextContent('Open in Google Maps');
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-DIALOG-005: loads or goes back, warns before replacing unsaved work, and loads nothing with too few positions', () => {
|
||||
const w = atPreview();
|
||||
const { rerender } = render(<WalkImportDialog walkImport={w} hasUnsavedChanges />);
|
||||
expect(screen.getByText('Loading the walk replaces the unsaved changes in the planner.')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('walk-import-load'));
|
||||
expect(w.load).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByText('Use another link'));
|
||||
expect(w.back).toHaveBeenCalledTimes(1);
|
||||
// Enter does not read again while the stops are shown.
|
||||
fireEvent.submit(screen.getByTestId('walk-import-preview').closest('form') as HTMLFormElement);
|
||||
expect(w.read).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<WalkImportDialog walkImport={atPreview({ canLoad: false, unplaced: 2 })} hasUnsavedChanges={false} />);
|
||||
expect(screen.getByTestId('walk-import-load')).toBeDisabled();
|
||||
expect(screen.getByText('A walk needs at least two stops with a position.')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 stops have no position in the link and are left out.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WalkImportDraftNotice (CW-07)', () => {
|
||||
it('FE-WALK-IMPORT-DIALOG-006: says where an unsaved walk came from, and nothing for any other draft', () => {
|
||||
const { rerender } = render(
|
||||
<WalkImportDraftNotice planner={{ draftSource: 'google_maps', mode: { type: 'new-draft' } }} />
|
||||
);
|
||||
expect(
|
||||
screen.getByText('From a Google Maps walking route. TREK planned the way between the stops itself.')
|
||||
).toBeInTheDocument();
|
||||
rerender(
|
||||
<WalkImportDraftNotice planner={{ draftSource: 'google_maps', mode: { type: 'edit-saved', placeId: 3 } }} />
|
||||
);
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
rerender(<WalkImportDraftNotice planner={{ draftSource: null, mode: { type: 'new-draft' } }} />);
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { WalkImportPreviewResponse, WalkImportStation } from '@trek/shared';
|
||||
import { AlertTriangle, ExternalLink, Footprints, Link2, Loader2, MapPinOff, Route } from 'lucide-react';
|
||||
import { useId, type FormEvent } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { EditorField, INPUT } from '../../shared/dialogParts';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
} from '../../shared/DialogShell';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { TourNotice } from '../tourParts';
|
||||
import type { WalkImport } from './useWalkImport';
|
||||
|
||||
/** One stop of the link, in its place in the order; a stop without a position says so instead of a position. */
|
||||
function WalkImportStationRow({ station, number }: { station: WalkImportStation; number: number }) {
|
||||
const { t } = useTranslation();
|
||||
const position =
|
||||
station.lat !== null && station.lng !== null ? `${station.lat.toFixed(5)}, ${station.lng.toFixed(5)}` : null;
|
||||
const placed = position !== null;
|
||||
return (
|
||||
<li
|
||||
data-testid="walk-import-station"
|
||||
data-unresolved={placed ? undefined : 'true'}
|
||||
className={`flex items-start gap-2.5 rounded-[10px] px-2.5 py-2 ${placed ? 'bg-surface-card' : 'border border-dashed border-edge'}`}
|
||||
>
|
||||
<span
|
||||
className="mt-px grid h-5 w-5 flex-none place-items-center rounded-full bg-surface-secondary font-geist font-semibold text-content-muted"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span
|
||||
className={`break-words font-medium leading-snug ${placed ? 'text-content' : 'text-content-muted'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{station.name ?? t('tours.walk.stationNumbered', { number })}
|
||||
</span>
|
||||
{placed ? (
|
||||
<span className="font-geist text-content-faint" style={fs(11)}>
|
||||
{position}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-warning" style={fs(11, 'body')}>
|
||||
<MapPinOff size={11} strokeWidth={2.2} className="flex-none" />
|
||||
{t('tours.walkImport.noPosition')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** The step that asks for the link. */
|
||||
function WalkImportLinkStep({ walkImport }: { walkImport: WalkImport }) {
|
||||
const { t } = useTranslation();
|
||||
const inputId = useId();
|
||||
return (
|
||||
<EditorField
|
||||
label={t('tours.walkImport.urlLabel')}
|
||||
htmlFor={inputId}
|
||||
hint={t('tours.walkImport.urlHint')}
|
||||
error={walkImport.failure}
|
||||
>
|
||||
<input
|
||||
id={inputId}
|
||||
type="text"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={INPUT}
|
||||
value={walkImport.url}
|
||||
placeholder="https://maps.app.goo.gl/…"
|
||||
disabled={walkImport.phase === 'reading'}
|
||||
onChange={(event) => walkImport.setUrl(event.target.value)}
|
||||
data-testid="walk-import-url"
|
||||
/>
|
||||
</EditorField>
|
||||
);
|
||||
}
|
||||
|
||||
/** The step that shows what the link holds, before anything is loaded. */
|
||||
function WalkImportPreviewStep({
|
||||
preview,
|
||||
unplaced,
|
||||
canLoad,
|
||||
hasUnsavedChanges,
|
||||
}: {
|
||||
preview: WalkImportPreviewResponse;
|
||||
unplaced: number;
|
||||
canLoad: boolean;
|
||||
hasUnsavedChanges: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-4" data-testid="walk-import-preview">
|
||||
<p className="m-0 flex flex-wrap items-center gap-x-2 gap-y-1 text-content-muted" style={fs(12, 'body')}>
|
||||
<Footprints size={13} strokeWidth={2} className="flex-none" />
|
||||
<span>{t('tours.walkImport.source')}</span>
|
||||
<a
|
||||
href={preview.resolved_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-accent hover:underline"
|
||||
data-testid="walk-import-source"
|
||||
>
|
||||
{t('tours.walkImport.openSource')}
|
||||
<ExternalLink size={12} strokeWidth={2} className="flex-none" />
|
||||
</a>
|
||||
</p>
|
||||
<TourNotice icon={<Route size={13} strokeWidth={2} />}>{t('tours.walkImport.recalculated')}</TourNotice>
|
||||
<DialogSection label={t('tours.walkImport.stops')}>
|
||||
<ol className="m-0 flex list-none flex-col gap-1 p-0">
|
||||
{preview.stations.map((station, index) => (
|
||||
<WalkImportStationRow key={index} station={station} number={index + 1} />
|
||||
))}
|
||||
</ol>
|
||||
</DialogSection>
|
||||
{unplaced > 0 && (
|
||||
<TourNotice tone="warning" icon={<MapPinOff size={13} strokeWidth={2} />}>
|
||||
{t('tours.walkImport.unresolved', { count: unplaced })}
|
||||
</TourNotice>
|
||||
)}
|
||||
{!canLoad && <TourNotice tone="warning">{t('tours.walkImport.tooFewPositions')}</TourNotice>}
|
||||
{hasUnsavedChanges && (
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
{t('tours.walkImport.replacesDraft')}
|
||||
</TourNotice>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The buttons of each step: read the link, or go back and load the walk. */
|
||||
function WalkImportFooter({ walkImport }: { walkImport: WalkImport }) {
|
||||
const { t } = useTranslation();
|
||||
const reading = walkImport.phase === 'reading';
|
||||
return (
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={walkImport.close}>{t('common.cancel')}</DialogButton>
|
||||
{walkImport.preview ? (
|
||||
<>
|
||||
<DialogButton onClick={walkImport.back}>{t('tours.walkImport.back')}</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
icon={<Footprints size={14} strokeWidth={2} />}
|
||||
disabled={!walkImport.canLoad}
|
||||
onClick={walkImport.load}
|
||||
data-testid="walk-import-load"
|
||||
>
|
||||
{t('tours.walkImport.load')}
|
||||
</DialogButton>
|
||||
</>
|
||||
) : (
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
icon={
|
||||
reading ? (
|
||||
<Loader2 size={14} strokeWidth={2} className="animate-spin" />
|
||||
) : (
|
||||
<Link2 size={14} strokeWidth={2} />
|
||||
)
|
||||
}
|
||||
disabled={!walkImport.canRead}
|
||||
onClick={() => void walkImport.read()}
|
||||
data-testid="walk-import-read"
|
||||
>
|
||||
{t('tours.walkImport.read')}
|
||||
</DialogButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-07: a city walk from a Google Maps walking route, opened from "Add tour".
|
||||
* First the link, then its stops in their order with the ones the link gives
|
||||
* no position marked, where the route came from, and that TREK plans the way
|
||||
* between them itself. Only the load button hands the walk to the planner.
|
||||
*/
|
||||
export function WalkImportDialog({
|
||||
walkImport,
|
||||
hasUnsavedChanges,
|
||||
}: {
|
||||
walkImport: WalkImport;
|
||||
hasUnsavedChanges: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const titleId = useId();
|
||||
if (!walkImport.open) return null;
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!walkImport.preview && walkImport.canRead) void walkImport.read();
|
||||
};
|
||||
const head = (
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<Footprints size={20} strokeWidth={2} className="text-content" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={titleId}
|
||||
onClose={walkImport.close}
|
||||
title={t('tours.walkImport.title')}
|
||||
sub={t('tours.walkImport.intro')}
|
||||
subWraps
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={walkImport.close}
|
||||
labelledBy={titleId}
|
||||
width="narrow"
|
||||
align="top"
|
||||
onSubmit={submit}
|
||||
header={head}
|
||||
footer={<WalkImportFooter walkImport={walkImport} />}
|
||||
>
|
||||
{walkImport.preview ? (
|
||||
<WalkImportPreviewStep
|
||||
preview={walkImport.preview}
|
||||
unplaced={walkImport.unplaced}
|
||||
canLoad={walkImport.canLoad}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
/>
|
||||
) : (
|
||||
<WalkImportLinkStep walkImport={walkImport} />
|
||||
)}
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** CW-07: said in the planner while a walk read from Google Maps is not saved yet. */
|
||||
export function WalkImportDraftNotice({ planner }: { planner: Pick<TourPlannerController, 'draftSource' | 'mode'> }) {
|
||||
const { t } = useTranslation();
|
||||
if (planner.draftSource !== 'google_maps' || planner.mode.type !== 'new-draft') return null;
|
||||
return (
|
||||
<TourNotice role="status" icon={<Footprints size={13} strokeWidth={2} />}>
|
||||
{t('tours.walkImport.draftNotice')}
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { WalkImportPreviewResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
import { tourWalkImportRepo } from '../../../repo/tourWalkImportRepo';
|
||||
import { useWalkImport } from './useWalkImport';
|
||||
|
||||
// FE-WALK-IMPORT-001 to FE-WALK-IMPORT-007
|
||||
|
||||
const ROUTE = 'https://www.google.com/maps/dir/Cathedral/Market/Park/data=!3e2';
|
||||
const PREVIEW: WalkImportPreviewResponse = {
|
||||
stations: [
|
||||
{ name: 'Cathedral', lat: 48.1, lng: 11.5 },
|
||||
{ name: 'Market', lat: null, lng: null },
|
||||
{ name: null, lat: 48.12, lng: 11.52 },
|
||||
{ name: 'Park', lat: 48.13, lng: 11.53 },
|
||||
],
|
||||
resolved_url: ROUTE,
|
||||
};
|
||||
|
||||
let preview: ReturnType<typeof vi.spyOn>;
|
||||
const planner = () => ({ tripId: 5, startDerivedTour: vi.fn(() => true) });
|
||||
|
||||
function setup(p = planner()) {
|
||||
const hook = renderHook(() => useWalkImport({ planner: p }), { wrapper: TranslationProvider });
|
||||
return { ...hook, planner: p };
|
||||
}
|
||||
|
||||
async function readLink(result: ReturnType<typeof setup>['result'], text: string) {
|
||||
act(() => result.current.show());
|
||||
act(() => result.current.setUrl(text));
|
||||
await act(() => result.current.read());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
preview = vi.spyOn(tourWalkImportRepo, 'preview').mockResolvedValue(PREVIEW);
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('useWalkImport (CW-07)', () => {
|
||||
it('FE-WALK-IMPORT-001: reads the link and shows its stops, counting the ones without a position', async () => {
|
||||
const { result } = setup();
|
||||
expect(result.current.open).toBe(false);
|
||||
act(() => result.current.show());
|
||||
expect(result.current.open).toBe(true);
|
||||
expect(result.current.canRead).toBe(false);
|
||||
act(() => result.current.setUrl(' https://maps.app.goo.gl/abc '));
|
||||
expect(result.current.canRead).toBe(true);
|
||||
await act(() => result.current.read());
|
||||
expect(preview).toHaveBeenCalledWith(5, 'https://maps.app.goo.gl/abc', expect.any(AbortSignal));
|
||||
expect(result.current.phase).toBe('preview');
|
||||
expect(result.current.preview).toBe(PREVIEW);
|
||||
expect(result.current.unplaced).toBe(1);
|
||||
expect(result.current.canLoad).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-002: takes the one link out of the share text of the Google Maps app, and sends anything else as typed', async () => {
|
||||
const { result } = setup();
|
||||
await readLink(result, 'Walk through the old town\nhttps://maps.app.goo.gl/abc');
|
||||
expect(preview).toHaveBeenLastCalledWith(5, 'https://maps.app.goo.gl/abc', expect.any(AbortSignal));
|
||||
await readLink(result, 'https://maps.app.goo.gl/a https://maps.app.goo.gl/b');
|
||||
expect(preview).toHaveBeenLastCalledWith(
|
||||
5,
|
||||
'https://maps.app.goo.gl/a https://maps.app.goo.gl/b',
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
preview.mockClear();
|
||||
act(() => result.current.setUrl(' '));
|
||||
await act(() => result.current.read());
|
||||
expect(preview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-003: a refusal is said in the reader’s language, offline and other failures too', async () => {
|
||||
const { result } = setup();
|
||||
preview.mockRejectedValueOnce({
|
||||
response: { status: 400, data: { error: 'Not on foot.', reason: 'not_walking' } },
|
||||
});
|
||||
await readLink(result, ROUTE);
|
||||
expect(result.current.phase).toBe('link');
|
||||
expect(result.current.failure).toBe(
|
||||
'That route is not planned on foot. Switch Google Maps to walking and share the route again.'
|
||||
);
|
||||
preview.mockRejectedValueOnce(new OnlineOnlyError());
|
||||
await act(() => result.current.read());
|
||||
expect(result.current.failure).toBe('Reading a link needs a connection.');
|
||||
preview.mockRejectedValueOnce(new Error('Network Error'));
|
||||
await act(() => result.current.read());
|
||||
expect(result.current.failure).toBe('The link could not be read just now. Try again in a moment.');
|
||||
// The link stays as typed for another try.
|
||||
expect(result.current.url).toBe(ROUTE);
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-004: a newer read and closing drop the request before it answers', async () => {
|
||||
const answers: Array<(value: WalkImportPreviewResponse) => void> = [];
|
||||
preview.mockImplementation(() => new Promise((resolve) => answers.push(resolve)));
|
||||
const { result } = setup();
|
||||
act(() => result.current.show());
|
||||
act(() => result.current.setUrl(ROUTE));
|
||||
let first: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
first = result.current.read();
|
||||
});
|
||||
expect(result.current.phase).toBe('reading');
|
||||
expect(result.current.canRead).toBe(false);
|
||||
const firstSignal = preview.mock.calls[0][2] as AbortSignal;
|
||||
let second: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
second = result.current.read();
|
||||
});
|
||||
expect(firstSignal.aborted).toBe(true);
|
||||
const secondSignal = preview.mock.calls[1][2] as AbortSignal;
|
||||
act(() => result.current.close());
|
||||
expect(secondSignal.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
answers.forEach((resolve) => resolve(PREVIEW));
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(result.current.preview).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-005: a failure that comes after closing is not shown, and unmounting drops the request', async () => {
|
||||
let fail: (reason: unknown) => void = () => undefined;
|
||||
preview.mockImplementation(() => new Promise((_resolve, reject) => (fail = reject)));
|
||||
const { result, unmount } = setup();
|
||||
act(() => result.current.show());
|
||||
act(() => result.current.setUrl(ROUTE));
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
pending = result.current.read();
|
||||
});
|
||||
act(() => result.current.close());
|
||||
await act(async () => {
|
||||
fail(new Error('late'));
|
||||
await pending;
|
||||
});
|
||||
expect(result.current.failure).toBeNull();
|
||||
|
||||
act(() => result.current.show());
|
||||
act(() => result.current.setUrl(ROUTE));
|
||||
act(() => {
|
||||
void result.current.read();
|
||||
});
|
||||
const signal = preview.mock.calls[1][2] as AbortSignal;
|
||||
unmount();
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-006: loads only the stops with a position, in order and with their names, as a walk from Google Maps', async () => {
|
||||
const { result, planner: p } = setup();
|
||||
await readLink(result, ROUTE);
|
||||
act(() => result.current.load());
|
||||
expect(p.startDerivedTour).toHaveBeenCalledExactlyOnceWith({
|
||||
name: 'Walk from Google Maps',
|
||||
difficulty: 2,
|
||||
points: [
|
||||
{ lat: 48.1, lng: 11.5, name: 'Cathedral' },
|
||||
{ lat: 48.12, lng: 11.52, name: null },
|
||||
{ lat: 48.13, lng: 11.53, name: 'Park' },
|
||||
],
|
||||
inPlace: false,
|
||||
tourType: 'walk',
|
||||
source: 'google_maps',
|
||||
});
|
||||
expect(result.current.open).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-WALK-IMPORT-007: stays open when the planner does not take it, and loads nothing with fewer than two positions', async () => {
|
||||
const refusing = { tripId: 5, startDerivedTour: vi.fn(() => false) };
|
||||
const { result } = setup(refusing);
|
||||
await readLink(result, ROUTE);
|
||||
act(() => result.current.load());
|
||||
expect(refusing.startDerivedTour).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.open).toBe(true);
|
||||
|
||||
act(() => result.current.back());
|
||||
expect(result.current.phase).toBe('link');
|
||||
expect(result.current.preview).toBeNull();
|
||||
expect(result.current.url).toBe(ROUTE);
|
||||
|
||||
preview.mockResolvedValueOnce({
|
||||
...PREVIEW,
|
||||
stations: [PREVIEW.stations[0], PREVIEW.stations[1]],
|
||||
});
|
||||
await act(() => result.current.read());
|
||||
expect(result.current.canLoad).toBe(false);
|
||||
act(() => result.current.load());
|
||||
expect(refusing.startDerivedTour).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { WalkImportPreviewResponse, WalkImportStation } from '@trek/shared';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
import { tourWalkImportRepo, walkImportRefusalOf } from '../../../repo/tourWalkImportRepo';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
|
||||
type Phase = 'link' | 'reading' | 'preview';
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
/** A stop the link gives a position. */
|
||||
type PlacedStation = WalkImportStation & { lat: number; lng: number };
|
||||
|
||||
const isPlaced = (station: WalkImportStation): station is PlacedStation => station.lat !== null && station.lng !== null;
|
||||
|
||||
const WORD = /\s+/;
|
||||
const HTTPS_LINK = /^https:\/\/\S+$/i;
|
||||
|
||||
/**
|
||||
* The link in what was pasted. The Google Maps app shares a route as its
|
||||
* title and the link; a text holding exactly one https link among other words
|
||||
* is taken as that link. Anything else goes to the server as typed.
|
||||
*/
|
||||
function linkIn(text: string): string {
|
||||
const typed = text.trim();
|
||||
const links = typed.split(WORD).filter((word) => HTTPS_LINK.test(word));
|
||||
return links.length === 1 ? links[0] : typed;
|
||||
}
|
||||
|
||||
/** What the dialog says when a link was not read, in the reader's language. */
|
||||
function failureOf(err: unknown, t: Translate): string {
|
||||
if (err instanceof OnlineOnlyError) return t('tours.walkImport.offline');
|
||||
const reason = walkImportRefusalOf(err);
|
||||
return reason ? t(`tours.walkImport.refusal.${reason}`) : t('tours.walkImport.failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-07: a city walk from a Google Maps walking route. The dialog reads the
|
||||
* link pasted into it (the server follows a short link and refuses a route not
|
||||
* on foot), shows the stops in their order, those without a position marked,
|
||||
* and only on an explicit load hands the positioned stops to the planner as a
|
||||
* new, unsaved walk with their names, which TREK then routes on foot itself.
|
||||
* Closing, going back or a failure leaves the planner as it was.
|
||||
*/
|
||||
export function useWalkImport({ planner }: { planner: Pick<TourPlannerController, 'tripId' | 'startDerivedTour'> }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
const [phase, setPhase] = useState<Phase>('link');
|
||||
const [preview, setPreview] = useState<WalkImportPreviewResponse | null>(null);
|
||||
const [failure, setFailure] = useState<string | null>(null);
|
||||
const requestRef = useRef<AbortController | null>(null);
|
||||
|
||||
// A read still under way when the planner goes is dropped with it.
|
||||
useEffect(() => () => requestRef.current?.abort(), []);
|
||||
|
||||
const stations = preview?.stations ?? [];
|
||||
const placed = stations.filter(isPlaced);
|
||||
|
||||
const reset = () => {
|
||||
requestRef.current?.abort();
|
||||
requestRef.current = null;
|
||||
setPhase('link');
|
||||
setPreview(null);
|
||||
setFailure(null);
|
||||
};
|
||||
|
||||
const show = () => {
|
||||
reset();
|
||||
setUrl('');
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const read = async () => {
|
||||
const link = linkIn(url);
|
||||
if (!link) return;
|
||||
requestRef.current?.abort();
|
||||
const request = new AbortController();
|
||||
requestRef.current = request;
|
||||
setPhase('reading');
|
||||
setFailure(null);
|
||||
try {
|
||||
const answer = await tourWalkImportRepo.preview(planner.tripId, link, request.signal);
|
||||
if (request.signal.aborted) return;
|
||||
setPreview(answer);
|
||||
setPhase('preview');
|
||||
} catch (err: unknown) {
|
||||
if (request.signal.aborted) return;
|
||||
setFailure(failureOf(err, t));
|
||||
setPhase('link');
|
||||
}
|
||||
};
|
||||
|
||||
const load = () => {
|
||||
if (placed.length < 2) return;
|
||||
const started = planner.startDerivedTour({
|
||||
name: t('tours.walkImport.defaultName'),
|
||||
difficulty: 2,
|
||||
points: placed.map(({ lat, lng, name }) => ({ lat, lng, name })),
|
||||
inPlace: false,
|
||||
tourType: 'walk',
|
||||
source: 'google_maps',
|
||||
});
|
||||
if (started) close();
|
||||
};
|
||||
|
||||
return {
|
||||
open,
|
||||
show,
|
||||
close,
|
||||
url,
|
||||
setUrl,
|
||||
phase,
|
||||
preview,
|
||||
failure,
|
||||
/** How many stops the link leaves without a position; they are left out on load. */
|
||||
unplaced: stations.length - placed.length,
|
||||
canRead: phase !== 'reading' && url.trim().length > 0,
|
||||
canLoad: phase === 'preview' && placed.length >= 2,
|
||||
read,
|
||||
/** Back to the link, which stays as typed. */
|
||||
back: reset,
|
||||
load,
|
||||
};
|
||||
}
|
||||
|
||||
export type WalkImport = ReturnType<typeof useWalkImport>;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import apiClient from '../api/client';
|
||||
import { tourWalkImportApi } from '../api/tourWalkImport';
|
||||
import { OnlineOnlyError } from './onlineOnly';
|
||||
import { tourWalkImportRepo, walkImportRefusalOf } from './tourWalkImportRepo';
|
||||
|
||||
// FE-REPO-WALK-IMPORT-001 to FE-REPO-WALK-IMPORT-003
|
||||
|
||||
const network = vi.hoisted(() => ({ offline: false }));
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: () => network.offline }));
|
||||
|
||||
const answer = {
|
||||
stations: [
|
||||
{ name: 'Cathedral', lat: 48.1, lng: 11.5 },
|
||||
{ name: 'Market', lat: null, lng: null },
|
||||
],
|
||||
resolved_url: 'https://www.google.com/maps/dir/Cathedral/Market/data=!3e2',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
network.offline = false;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('tourWalkImportRepo', () => {
|
||||
it('FE-REPO-WALK-IMPORT-001: reads only online, and offline asks nothing', async () => {
|
||||
network.offline = true;
|
||||
const post = vi.spyOn(apiClient, 'post');
|
||||
await expect(tourWalkImportRepo.preview(5, 'https://maps.app.goo.gl/abc')).rejects.toBeInstanceOf(OnlineOnlyError);
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-REPO-WALK-IMPORT-002: posts the link to the trip’s preview and reads the answer through the contract', async () => {
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: answer });
|
||||
const controller = new AbortController();
|
||||
expect(await tourWalkImportRepo.preview(5, 'https://maps.app.goo.gl/abc', controller.signal)).toEqual(answer);
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/trips/5/tours/import/google-maps/preview',
|
||||
{ url: 'https://maps.app.goo.gl/abc' },
|
||||
{ signal: controller.signal, timeout: 20_000 }
|
||||
);
|
||||
// An answer the contract does not describe is a failure, not a preview.
|
||||
vi.spyOn(tourWalkImportApi, 'preview').mockResolvedValue({ ...answer, stations: [] });
|
||||
await expect(tourWalkImportRepo.preview(5, 'https://maps.app.goo.gl/abc')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('FE-REPO-WALK-IMPORT-003: names the reason of a refusal, and nothing for any other failure', () => {
|
||||
const refusal = (data: unknown) => ({ response: { status: 400, data } });
|
||||
expect(walkImportRefusalOf(refusal({ error: 'Not on foot.', reason: 'not_walking' }))).toBe('not_walking');
|
||||
expect(walkImportRefusalOf(refusal({ error: 'Odd.', reason: 'teleport' }))).toBeNull();
|
||||
expect(walkImportRefusalOf(refusal({ error: 'Tours addon is not enabled' }))).toBeNull();
|
||||
expect(walkImportRefusalOf(new Error('Network Error'))).toBeNull();
|
||||
expect(walkImportRefusalOf(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
walkImportPreviewResponseSchema,
|
||||
walkImportRefusalResponseSchema,
|
||||
type WalkImportPreviewResponse,
|
||||
type WalkImportRefusal,
|
||||
} from '@trek/shared';
|
||||
import { tourWalkImportApi } from '../api/tourWalkImport';
|
||||
import { requireOnline } from './onlineOnly';
|
||||
|
||||
/**
|
||||
* CW-07, online only on purpose: the stops come from a link the server reads
|
||||
* (and, for a short link, follows to Google Maps) at the moment it is asked,
|
||||
* and nothing of them is kept until the walk is saved like any other tour.
|
||||
* Offline the dialog says so instead of reading.
|
||||
*/
|
||||
export const tourWalkImportRepo = {
|
||||
async preview(tripId: number | string, url: string, signal?: AbortSignal): Promise<WalkImportPreviewResponse> {
|
||||
requireOnline();
|
||||
return walkImportPreviewResponseSchema.parse(await tourWalkImportApi.preview(tripId, { url }, signal));
|
||||
},
|
||||
};
|
||||
|
||||
/** Why the server would not read a link, when its answer says so; null for any other failure. */
|
||||
export function walkImportRefusalOf(err: unknown): WalkImportRefusal | null {
|
||||
const body = (err as { response?: { data?: unknown } } | null)?.response?.data;
|
||||
const parsed = walkImportRefusalResponseSchema.safeParse(body);
|
||||
return parsed.success ? parsed.data.reason : null;
|
||||
}
|
||||
@@ -50,6 +50,8 @@ export function hikePlannerDefaults() {
|
||||
toggleMapOverlay: vi.fn(),
|
||||
tripId: 1,
|
||||
editWaypoints: vi.fn(),
|
||||
// CW-07: a draft planned in TREK, not read from a Google Maps walk.
|
||||
draftSource: null,
|
||||
// P2C-03: a tour opened at its first revision, with nothing known about others.
|
||||
revision: {
|
||||
base: null,
|
||||
|
||||
@@ -17,3 +17,24 @@ export const GOOGLE_SHORT_HOSTS = ['goo.gl', 'maps.app.goo.gl'];
|
||||
export function isGoogleMapsHost(hostname: string): boolean {
|
||||
return GOOGLE_SHORT_HOSTS.includes(hostname) || /^(www\.|maps\.)?google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(hostname);
|
||||
}
|
||||
|
||||
/** No credentials, no other port, https only: the shape every hop of a walk import must have. */
|
||||
function plainHttps(url: URL): boolean {
|
||||
return url.protocol === 'https:' && !url.username && !url.password && url.port === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-06: the short links a walk import follows at all, `maps.app.goo.gl/<code>`
|
||||
* and `goo.gl/maps/<code>`. Anything else on goo.gl was a general shortener
|
||||
* and could lead anywhere, so it is not followed.
|
||||
*/
|
||||
export function isGoogleMapsShortLink(url: URL): boolean {
|
||||
if (!plainHttps(url)) return false;
|
||||
if (url.hostname === 'maps.app.goo.gl') return /^\/[A-Za-z0-9_-]+\/?$/.test(url.pathname);
|
||||
return url.hostname === 'goo.gl' && /^\/maps\/[A-Za-z0-9_-]+\/?$/.test(url.pathname);
|
||||
}
|
||||
|
||||
/** CW-06: where a short link may lead: Google Maps itself, which is read and never fetched. */
|
||||
export function isGoogleMapsLongLink(url: URL): boolean {
|
||||
return plainHttps(url) && isGoogleMapsHost(url.hostname) && !GOOGLE_SHORT_HOSTS.includes(url.hostname);
|
||||
}
|
||||
|
||||
@@ -96,11 +96,11 @@ function fromQuery(url: URL): DirWaypoint[] {
|
||||
.filter((wp): wp is DirWaypoint => wp !== null);
|
||||
}
|
||||
|
||||
/** The path form, where the stops are the segments and the blob holds their positions. */
|
||||
function fromPath(url: URL): DirWaypoint[] {
|
||||
/** The path form's stops, as the segments name them, and the data blob after them. */
|
||||
function pathStops(url: URL): { names: DirWaypoint[]; data: string } {
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
const at = parts.indexOf('dir');
|
||||
if (at < 0) return [];
|
||||
if (at < 0) return { names: [], data: '' };
|
||||
|
||||
const names: DirWaypoint[] = [];
|
||||
let data = '';
|
||||
@@ -116,12 +116,16 @@ function fromPath(url: URL): DirWaypoint[] {
|
||||
const wp = asWaypoint(decodeSegment(part));
|
||||
if (wp) names.push(wp);
|
||||
}
|
||||
if (!names.length) return [];
|
||||
return { names, data };
|
||||
}
|
||||
|
||||
// Positions from the blob, but only when there is exactly one for each stop. A partial
|
||||
// match cannot be lined up — Google drops the pair for "your location" and adds pairs
|
||||
// of its own for a route through several countries — and half a link read confidently
|
||||
// is worse than a link read as names and geocoded.
|
||||
/**
|
||||
* Positions from the blob, but only when there is exactly one for each stop. A partial
|
||||
* match cannot be lined up: Google drops the pair for "your location" and adds pairs
|
||||
* of its own for a route through several countries, and half a link read confidently
|
||||
* is worse than a link read as names and geocoded.
|
||||
*/
|
||||
function withPairPositions(names: DirWaypoint[], data: string): DirWaypoint[] {
|
||||
const pairs = [...data.matchAll(DATA_LNG_LAT)]
|
||||
.map((m) => ({ lng: Number.parseFloat(m[1]), lat: Number.parseFloat(m[2]) }))
|
||||
.filter((p) => onEarth(p.lat, p.lng));
|
||||
@@ -130,13 +134,104 @@ function fromPath(url: URL): DirWaypoint[] {
|
||||
return names.map((wp, i) => (wp.lat !== null ? wp : { name: wp.name, lat: pairs[i].lat, lng: pairs[i].lng }));
|
||||
}
|
||||
|
||||
/** The path form, where the stops are the segments and the blob holds their positions. */
|
||||
function fromPath(url: URL, positions: DirectionsPositions): DirWaypoint[] {
|
||||
const { names, data } = pathStops(url);
|
||||
if (!names.length) return [];
|
||||
return positions === 'stop-blocks' ? withStopBlockPositions(names, data) : withPairPositions(names, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* One field of the data blob. Google writes a protobuf message as `!<field><type><value>`
|
||||
* tokens: `!4m66` is field 4, a message holding the 66 tokens after it (its own and its
|
||||
* children's), `!1d-8.61` a double, `!1s0x…` a string, `!3e2` an enum.
|
||||
*/
|
||||
interface BlobField {
|
||||
key: string;
|
||||
value: string;
|
||||
children: BlobField[];
|
||||
}
|
||||
|
||||
const BLOB_TOKEN = /^(\d+)([A-Za-z])(.*)$/;
|
||||
|
||||
/**
|
||||
* The blob as its tree of fields; null for a token that is not a field, or a message
|
||||
* that claims more tokens than follow it.
|
||||
*/
|
||||
function blobFields(data: string): BlobField[] | null {
|
||||
const tokens = decodeSegment(data.slice('data='.length)).split('!').filter(Boolean);
|
||||
const root: BlobField = { key: '', value: '', children: [] };
|
||||
const open: Array<{ field: BlobField; left: number }> = [{ field: root, left: tokens.length }];
|
||||
for (const token of tokens) {
|
||||
const match = BLOB_TOKEN.exec(token);
|
||||
const parent = open.at(-1);
|
||||
if (!match || !parent || open.some((entry) => entry.left < 1)) return null;
|
||||
const field: BlobField = { key: match[1] + match[2], value: match[3], children: [] };
|
||||
parent.field.children.push(field);
|
||||
for (const entry of open) entry.left -= 1;
|
||||
if (match[2] === 'm') open.push({ field, left: Number(match[3]) });
|
||||
while (open.length > 1 && open.at(-1)?.left === 0) open.pop();
|
||||
}
|
||||
return open.length === 1 ? root.children : null;
|
||||
}
|
||||
|
||||
/** The route message: the first `!4m` that lists two stop blocks (`!1m`) or more. */
|
||||
function routeMessage(fields: readonly BlobField[]): BlobField | null {
|
||||
for (const field of fields) {
|
||||
if (field.key === '4m' && field.children.filter((child) => child.key === '1m').length >= 2) return field;
|
||||
const inner = routeMessage(field.children);
|
||||
if (inner) return inner;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop block's own position (`!2m2!1d<lng>!2d<lat>`). A point Google adds where the
|
||||
* line was dragged sits in a `!3m` of the block and is not the stop.
|
||||
*/
|
||||
function blockPosition(block: BlobField): { lat: number; lng: number } | null {
|
||||
const own = block.children.find((child) => child.key === '2m');
|
||||
const lng = Number.parseFloat(own?.children.find((child) => child.key === '1d')?.value ?? '');
|
||||
const lat = Number.parseFloat(own?.children.find((child) => child.key === '2d')?.value ?? '');
|
||||
return onEarth(lat, lng) ? { lat, lng } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-07: positions read from each stop's own block of the route message, so a line
|
||||
* dragged through extra points or a stop given as coordinates cannot shift them onto the
|
||||
* wrong stop. When the blocks and the stops do not pair up one to one, no stop takes a
|
||||
* position from the blob: a stop the link does not place stays without one.
|
||||
*/
|
||||
function withStopBlockPositions(names: DirWaypoint[], data: string): DirWaypoint[] {
|
||||
const fields = data ? blobFields(data) : null;
|
||||
const route = fields ? routeMessage(fields) : null;
|
||||
const blocks = route?.children.filter((child) => child.key === '1m') ?? [];
|
||||
if (blocks.length !== names.length) return names;
|
||||
return names.map((wp, i) => {
|
||||
const position = wp.lat === null ? blockPosition(blocks[i]) : null;
|
||||
return position ? { name: wp.name, ...position } : wp;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* How the stops of the path form are placed from the data blob: `pairs` lines up the
|
||||
* coordinate pairs with the stops when their counts agree (the road trip and places
|
||||
* imports, which geocode what stays a name), `stop-blocks` reads each stop's own block
|
||||
* and places nothing it cannot line up (the city walk import, which guesses nothing).
|
||||
*/
|
||||
export type DirectionsPositions = 'pairs' | 'stop-blocks';
|
||||
|
||||
/**
|
||||
* The stops of a shared directions link, in the order they are driven.
|
||||
*
|
||||
* Empty when the link is not a route, or holds fewer than two stops — one stop is a
|
||||
* place, and the place search box already takes those.
|
||||
*/
|
||||
export function parseDirectionsUrl(raw: string, limit = MAX_DIR_WAYPOINTS): DirWaypoint[] {
|
||||
export function parseDirectionsUrl(
|
||||
raw: string,
|
||||
limit = MAX_DIR_WAYPOINTS,
|
||||
positions: DirectionsPositions = 'pairs',
|
||||
): DirWaypoint[] {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
@@ -144,6 +239,51 @@ export function parseDirectionsUrl(raw: string, limit = MAX_DIR_WAYPOINTS): DirW
|
||||
return [];
|
||||
}
|
||||
const fromParams = fromQuery(url);
|
||||
const found = fromParams.length ? fromParams : fromPath(url);
|
||||
const found = fromParams.length ? fromParams : fromPath(url, positions);
|
||||
return found.length >= 2 ? found.slice(0, limit) : [];
|
||||
}
|
||||
|
||||
/** How a directions link says its route is travelled; `unknown` when it does not say, or says two things. */
|
||||
export type DirTravelMode = 'walking' | 'driving' | 'bicycling' | 'transit' | 'other' | 'unknown';
|
||||
|
||||
/** Google's travel-mode field in a route's data blob: !3e0 car, !3e1 bike, !3e2 on foot, !3e3 transit. */
|
||||
const DATA_MODE = /!3e(\d+)/g;
|
||||
const DATA_MODES: Readonly<Partial<Record<string, DirTravelMode>>> = {
|
||||
'0': 'driving',
|
||||
'1': 'bicycling',
|
||||
'2': 'walking',
|
||||
'3': 'transit',
|
||||
};
|
||||
const PARAM_MODES: Readonly<Partial<Record<string, DirTravelMode>>> = {
|
||||
driving: 'driving',
|
||||
walking: 'walking',
|
||||
bicycling: 'bicycling',
|
||||
transit: 'transit',
|
||||
};
|
||||
|
||||
/** The `data=` path segment of a route, decoded; '' when there is none. Only there does !3e name the mode. */
|
||||
function dataSegmentOf(url: URL): string {
|
||||
const part = url.pathname.split('/').find((segment) => segment.startsWith('data='));
|
||||
return part ? decodeSegment(part) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-07: the travel mode a directions link states, from the documented
|
||||
* `travelmode=` parameter or the `!3e` field of the path's data blob. Exactly one
|
||||
* mode stated is the answer; none, or two different ones, is `unknown`.
|
||||
*/
|
||||
export function directionsTravelMode(raw: string): DirTravelMode {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
if (!isDirectionsUrl(url.href)) return 'unknown';
|
||||
const said = new Set<DirTravelMode>();
|
||||
const param = url.searchParams.get('travelmode');
|
||||
if (param !== null) said.add(PARAM_MODES[param.trim().toLowerCase()] ?? 'other');
|
||||
for (const [, code] of dataSegmentOf(url).matchAll(DATA_MODE)) said.add(DATA_MODES[code] ?? 'other');
|
||||
const [only] = said;
|
||||
return said.size === 1 && only ? only : 'unknown';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PlaceImportService } from './place-import.service';
|
||||
import { CollectionGpxProvider } from './providers/collection-gpx.provider';
|
||||
import { GoogleDirectionsProvider } from './providers/google-directions.provider';
|
||||
import { GoogleListProvider } from './providers/google-list.provider';
|
||||
import { GoogleMapsShortLinkProvider } from './providers/google-maps-short-link.provider';
|
||||
import { GpxProvider } from './providers/gpx.provider';
|
||||
import { KmlProvider } from './providers/kml.provider';
|
||||
import { NaverListProvider } from './providers/naver-list.provider';
|
||||
@@ -13,7 +14,9 @@ import { Module } from '@nestjs/common';
|
||||
* table of its own: PlacesModule, CollectionsModule and ToursModule import it
|
||||
* and persist what it reads. It imports no other
|
||||
* domain: the stops of a directions link that carry only a name are geocoded
|
||||
* through a function the importer hands in (MapsService.geocodeQuery).
|
||||
* through a function the importer hands in (MapsService.geocodeQuery). A
|
||||
* Google Maps short link the city walk import is given is followed through
|
||||
* GoogleMapsShortLinkProvider, hop by hop against its own allow-list (CW-06).
|
||||
*/
|
||||
@Module({
|
||||
providers: [
|
||||
@@ -24,6 +27,7 @@ import { Module } from '@nestjs/common';
|
||||
GoogleListProvider,
|
||||
GoogleDirectionsProvider,
|
||||
NaverListProvider,
|
||||
GoogleMapsShortLinkProvider,
|
||||
],
|
||||
exports: [PlaceImportService],
|
||||
})
|
||||
|
||||
@@ -8,10 +8,12 @@ import type {
|
||||
ListRead,
|
||||
NaverListPlace,
|
||||
PreparedGpxPlace,
|
||||
ShortLinkOutcome,
|
||||
} from './place-import.types';
|
||||
import { CollectionGpxProvider, type ExportedCollectionFile } from './providers/collection-gpx.provider';
|
||||
import { GoogleDirectionsProvider } from './providers/google-directions.provider';
|
||||
import { GoogleListProvider } from './providers/google-list.provider';
|
||||
import { GoogleMapsShortLinkProvider } from './providers/google-maps-short-link.provider';
|
||||
import { GpxProvider, type GpxExportInput, type GpxExportOptions, type GpxTourExport } from './providers/gpx.provider';
|
||||
import { KmlProvider } from './providers/kml.provider';
|
||||
import { NaverListProvider } from './providers/naver-list.provider';
|
||||
@@ -19,8 +21,10 @@ import { Injectable } from '@nestjs/common';
|
||||
import type { CollectionGpxExport, CollectionGpxReadResult } from '@trek/shared';
|
||||
|
||||
// The directions-link reading is also what the road trip planner reads a
|
||||
// shared route with, and what the import routes dispatch on.
|
||||
export { isDirectionsUrl, parseDirectionsUrl, MAX_DIR_WAYPOINTS } from './directions-url.helpers';
|
||||
// shared route with, what the import routes dispatch on, and what the city
|
||||
// walk import reads a walking route and its travel mode with (CW-07).
|
||||
export { directionsTravelMode, isDirectionsUrl, parseDirectionsUrl, MAX_DIR_WAYPOINTS } from './directions-url.helpers';
|
||||
export type { DirWaypoint } from './directions-url.helpers';
|
||||
export type {
|
||||
GpxExportDay,
|
||||
GpxExportInput,
|
||||
@@ -52,6 +56,7 @@ export class PlaceImportService {
|
||||
private readonly googleList: GoogleListProvider,
|
||||
private readonly googleDirections: GoogleDirectionsProvider,
|
||||
private readonly naverList: NaverListProvider,
|
||||
private readonly shortLinks: GoogleMapsShortLinkProvider,
|
||||
) {}
|
||||
|
||||
// ── GPX ────────────────────────────────────────────────────────────────────
|
||||
@@ -115,6 +120,11 @@ export class PlaceImportService {
|
||||
return this.naverList.read(url);
|
||||
}
|
||||
|
||||
/** CW-06: a Google Maps short link followed through its allow-list and the strict SSRF guard, hop by hop. */
|
||||
resolveGoogleShortLink(url: string): Promise<ShortLinkOutcome> {
|
||||
return this.shortLinks.resolve(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stops of a shared directions link, placed on the map.
|
||||
*
|
||||
|
||||
@@ -102,3 +102,12 @@ export class CollectionGpxError extends Error {
|
||||
this.name = 'CollectionGpxError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-06: where a Google Maps short link led. `refused` when a hop or the
|
||||
* landing link is not one the walk import may follow or read, `unreachable`
|
||||
* when the chain could not be followed in time or answered nonsense,
|
||||
* `too_many_redirects` and `no_redirect` as they say.
|
||||
*/
|
||||
export type ShortLinkOutcome =
|
||||
{ ok: true; url: string } | { ok: false; why: 'refused' | 'unreachable' | 'too_many_redirects' | 'no_redirect' };
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* CW-06: a Google Maps short link followed for the city walk import, and
|
||||
* nothing else.
|
||||
*
|
||||
* This is deliberately not the general follower (`safeFetchFollow`). That one
|
||||
* goes wherever a redirect points as long as the address is public; this one
|
||||
* checks every hop against the short-link allow-list before anything is
|
||||
* resolved or asked, and runs the SSRF guard in its strict form, so
|
||||
* ALLOW_INTERNAL_NETWORK and ALLOW_LINK_LOCAL_IPS do not open it. Each request
|
||||
* goes out pinned to the addresses the guard vetted, follows nothing by
|
||||
* itself, reads no body and carries nothing of the caller's. The whole chain,
|
||||
* name lookups included, has one deadline and at most three hops, and a
|
||||
* Location header longer than a link can be is refused. Where the chain ends,
|
||||
* the long link has to be Google Maps itself; it is read, never fetched.
|
||||
*/
|
||||
import { discardBody } from '../../../utils/cappedFetch';
|
||||
import { checkSsrf, createOutboundDispatcher } from '../../../utils/ssrfGuard';
|
||||
import { logDebug } from '../../audit/audit-log.logger';
|
||||
import { isGoogleMapsLongLink, isGoogleMapsShortLink } from '../../common/google-maps-hosts';
|
||||
import type { ShortLinkOutcome } from '../place-import.types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
export interface ShortLinkLimits {
|
||||
/** Requests made at most, one per hop. */
|
||||
hops: number;
|
||||
/** One deadline over the whole chain, the name lookups included. */
|
||||
deadlineMs: number;
|
||||
/** The longest Location header taken; a longer one is no link. */
|
||||
locationChars: number;
|
||||
}
|
||||
|
||||
export const SHORT_LINK_LIMITS: ShortLinkLimits = { hops: 3, deadlineMs: 8_000, locationChars: 8_192 };
|
||||
|
||||
/** The lookup inside the SSRF guard cannot be aborted; this holds it to the chain's deadline as well. */
|
||||
function beforeDeadline<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason);
|
||||
return;
|
||||
}
|
||||
const stop = () => reject(signal.reason);
|
||||
signal.addEventListener('abort', stop, { once: true });
|
||||
void work.then(resolve, reject).finally(() => signal.removeEventListener('abort', stop));
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GoogleMapsShortLinkProvider {
|
||||
/** Where a maps.app.goo.gl or goo.gl/maps link leads; nothing outside Google Maps is ever asked. */
|
||||
async resolve(raw: string, limits: ShortLinkLimits = SHORT_LINK_LIMITS): Promise<ShortLinkOutcome> {
|
||||
let start: URL;
|
||||
try {
|
||||
start = new URL(raw);
|
||||
} catch {
|
||||
return { ok: false, why: 'refused' };
|
||||
}
|
||||
const signal = AbortSignal.timeout(limits.deadlineMs);
|
||||
try {
|
||||
return await this.hop(start, 0, signal, limits);
|
||||
} catch (err: unknown) {
|
||||
// A deadline passed, a name that does not resolve, a connection that failed.
|
||||
logDebug(`[walk import] Google Maps short link not followed: ${err instanceof Error ? err.name : 'error'}`);
|
||||
return { ok: false, why: 'unreachable' };
|
||||
}
|
||||
}
|
||||
|
||||
// Recursion rather than a loop: each hop waits for the one before it.
|
||||
private async hop(
|
||||
url: URL,
|
||||
fetched: number,
|
||||
signal: AbortSignal,
|
||||
limits: ShortLinkLimits,
|
||||
): Promise<ShortLinkOutcome> {
|
||||
// Before any name lookup and before any request: only a short link is asked at all.
|
||||
if (!isGoogleMapsShortLink(url)) return { ok: false, why: 'refused' };
|
||||
if (fetched >= limits.hops) return { ok: false, why: 'too_many_redirects' };
|
||||
// Strict: a private address stays refused whatever ALLOW_INTERNAL_NETWORK says.
|
||||
const ssrf = await beforeDeadline(checkSsrf(url.href, true), signal);
|
||||
const addresses = ssrf.allowed ? (ssrf.resolvedIps ?? []) : [];
|
||||
if (addresses.length === 0) return { ok: false, why: 'refused' };
|
||||
const location = await this.locationOf(url, addresses, signal);
|
||||
if (location === null) return { ok: false, why: 'no_redirect' };
|
||||
if (location.length > limits.locationChars) return { ok: false, why: 'unreachable' };
|
||||
let next: URL;
|
||||
try {
|
||||
next = new URL(location, url);
|
||||
} catch {
|
||||
return { ok: false, why: 'refused' };
|
||||
}
|
||||
if (isGoogleMapsShortLink(next)) return this.hop(next, fetched + 1, signal, limits);
|
||||
// The long link is read by the caller and never fetched here.
|
||||
return isGoogleMapsLongLink(next) ? { ok: true, url: next.href } : { ok: false, why: 'refused' };
|
||||
}
|
||||
|
||||
/** One request through the pinned dispatcher, nothing followed and no body read: the Location of a 3xx, or null. */
|
||||
private async locationOf(url: URL, addresses: string[], signal: AbortSignal): Promise<string | null> {
|
||||
const dispatcher = createOutboundDispatcher(url.href, addresses);
|
||||
// Nothing of the caller's goes along: no cookie, no authorization, no referrer.
|
||||
// Node's fetch keeps no cookie jar, and credentials 'omit' says so for the record.
|
||||
const init: RequestInit = {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
credentials: 'omit',
|
||||
referrerPolicy: 'no-referrer',
|
||||
headers: { accept: 'text/html' },
|
||||
signal,
|
||||
};
|
||||
try {
|
||||
// The pinned dispatcher is undici's own type, the init's that of Node's bundled copy;
|
||||
// Object.assign joins the two without casting either away.
|
||||
const response = await fetch(url.href, Object.assign(init, { dispatcher }));
|
||||
discardBody(response);
|
||||
return response.status >= 300 && response.status < 400 ? response.headers.get('location') : null;
|
||||
} finally {
|
||||
void dispatcher.close().catch((err: unknown) => logDebug(`[walk import] dispatcher not closed: ${String(err)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { walkImportPreviewRequestSchema } from '@trek/shared';
|
||||
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
export class WalkImportPreviewDto extends createZodDto(walkImportPreviewRequestSchema) {}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { WalkImportPreviewDto } from './dto/tour-walk-import.dto';
|
||||
import { TourWalkImportService } from './tour-walk-import.service';
|
||||
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
|
||||
import { walkImportPreviewResponseSchema } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* CW-07 POST /api/trips/:tripId/tours/import/google-maps/preview: a Google
|
||||
* Maps walking route read into its stops; nothing is stored. Like the GPX
|
||||
* preview, the addon off or a trip out of reach answers 404, and a member who
|
||||
* may not edit places 403, since what comes next creates a tour.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/tours')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
|
||||
export class TourWalkImportController {
|
||||
constructor(private readonly walkImport: TourWalkImportService) {}
|
||||
|
||||
@RequirePermission('place_edit')
|
||||
@Post('import/google-maps/preview')
|
||||
@HttpCode(200)
|
||||
@ResponseContract(walkImportPreviewResponseSchema)
|
||||
async preview(@Body() body: WalkImportPreviewDto) {
|
||||
return await this.walkImport.preview(body.url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, 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 { TourWalkImportService } from './tour-walk-import.service';
|
||||
import { idSchema, walkImportPreviewRequestSchema } from '@trek/shared';
|
||||
|
||||
const preview = walkImportPreviewRequestSchema.shape;
|
||||
|
||||
/**
|
||||
* CW-07: the stops of a Google Maps walking route read from its link, over
|
||||
* the same service and the same checks as REST: access to the trip, and
|
||||
* 'place_edit' on it, since what comes next creates a tour. A refusal of the
|
||||
* link (a car route, a link that does not say, a short link leading elsewhere)
|
||||
* is the service's DomainError, which the registry answers as an error result
|
||||
* with the same English text REST sends.
|
||||
*/
|
||||
@McpController()
|
||||
export class TourWalkImportMcp {
|
||||
constructor(
|
||||
private readonly walkImport: TourWalkImportService,
|
||||
private readonly access: TripAccessService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
// Read by the addon gate of the tool here.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'preview_google_maps_walk',
|
||||
description:
|
||||
'Read the stops of a Google Maps walking route shared as a link (a maps.app.goo.gl short link or a google.com/maps/dir link), in order, for a city walk (tour_type walk): each stop with the name the link gives it and its position, or null coordinates when the link gives none (such a stop is not looked up or guessed), and resolved_url, the long link that was read. Only a route Google Maps marks as on foot is read; a car, bike or transit route, or one that does not say, is refused. Nothing is saved, and the line drawn in Google Maps is not taken over: TREK plans the way on foot between the stops again when the walk is created. Needs place editing permission on the trip.',
|
||||
inputSchema: {
|
||||
tripId: idSchema.describe('The trip the walk is meant for'),
|
||||
url: preview.url,
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async previewGoogleMapsWalk({ tripId, url }: { tripId: number; url: string }, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
return ok(await this.walkImport.preview(url));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { isGoogleMapsLongLink, isGoogleMapsShortLink } from '../common/google-maps-hosts';
|
||||
import {
|
||||
directionsTravelMode,
|
||||
isDirectionsUrl,
|
||||
parseDirectionsUrl,
|
||||
PlaceImportService,
|
||||
type DirWaypoint,
|
||||
} from '../place-import/place-import.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
TOUR_STATION_NAME_MAX,
|
||||
WALK_IMPORT_STATIONS_MAX,
|
||||
type WalkImportPreviewResponse,
|
||||
type WalkImportRefusal,
|
||||
type WalkImportStation,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** What API and MCP clients read; the planner says the same in the reader's language, by reason. */
|
||||
const REFUSAL_TEXT: Readonly<Record<WalkImportRefusal, string>> = {
|
||||
not_google_maps: 'Use a Google Maps route link: a maps.app.goo.gl short link or a google.com/maps/dir link.',
|
||||
not_a_route: 'That Google Maps link is not a route. Plan the walk in Google Maps and share the route.',
|
||||
not_walking: 'That route is not planned on foot. Switch Google Maps to walking and share the route again.',
|
||||
mode_unknown:
|
||||
'The link does not say clearly that the route is on foot. Choose walking in Google Maps and share it again.',
|
||||
short_link_refused: 'The short link leads somewhere other than Google Maps, so it was not followed.',
|
||||
short_link_failed: 'The short link could not be followed just now. Try again, or paste the long link.',
|
||||
too_few_stops: 'Fewer than two stops could be read from that link.',
|
||||
too_many_stops: 'That route has more stops than a walk can take.',
|
||||
};
|
||||
|
||||
/** Reasons that are Google's answer rather than the link's. */
|
||||
const UPSTREAM: ReadonlySet<WalkImportRefusal> = new Set<WalkImportRefusal>(['short_link_failed']);
|
||||
|
||||
function refuse(reason: WalkImportRefusal): DomainError {
|
||||
return new DomainError(UPSTREAM.has(reason) ? 502 : 400, REFUSAL_TEXT[reason], { code: reason, details: { reason } });
|
||||
}
|
||||
|
||||
/** A stop as the walk takes it: its name as the link spells it, cut to a station's length; no position guessed. */
|
||||
function stationOf(stop: DirWaypoint): WalkImportStation {
|
||||
const name = stop.name?.trim().slice(0, TOUR_STATION_NAME_MAX).trim() || null;
|
||||
return { name, lat: stop.lat, lng: stop.lng };
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-07: the stops of a Google Maps walking route shared as a link, in their
|
||||
* order, for the planner to show before anything is taken over. Only a route
|
||||
* the link marks as on foot is read. A stop the link gives no position stays
|
||||
* without one: nothing is looked up or guessed. A short link is followed only
|
||||
* through the narrow resolver of CW-06. Nothing is stored.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourWalkImportService {
|
||||
constructor(private readonly placeImport: PlaceImportService) {}
|
||||
|
||||
async preview(raw: string): Promise<WalkImportPreviewResponse> {
|
||||
const link = this.linkOf(raw);
|
||||
const route = isGoogleMapsShortLink(link) ? await this.followed(link) : link;
|
||||
if (!isGoogleMapsLongLink(route)) throw refuse('not_google_maps');
|
||||
if (!isDirectionsUrl(route.href)) throw refuse('not_a_route');
|
||||
const mode = directionsTravelMode(route.href);
|
||||
if (mode !== 'walking') throw refuse(mode === 'unknown' ? 'mode_unknown' : 'not_walking');
|
||||
const stops = parseDirectionsUrl(route.href, WALK_IMPORT_STATIONS_MAX + 1, 'stop-blocks');
|
||||
if (stops.length > WALK_IMPORT_STATIONS_MAX) throw refuse('too_many_stops');
|
||||
if (stops.length < 2) throw refuse('too_few_stops');
|
||||
return { stations: stops.map(stationOf), resolved_url: route.href };
|
||||
}
|
||||
|
||||
private linkOf(raw: string): URL {
|
||||
try {
|
||||
return new URL(raw.trim());
|
||||
} catch {
|
||||
throw refuse('not_google_maps');
|
||||
}
|
||||
}
|
||||
|
||||
private async followed(link: URL): Promise<URL> {
|
||||
const outcome = await this.placeImport.resolveGoogleShortLink(link.href);
|
||||
if ('why' in outcome) throw refuse(outcome.why === 'refused' ? 'short_link_refused' : 'short_link_failed');
|
||||
return new URL(outcome.url);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@ import { TourTrailGradeController } from './tour-trail-grade.controller';
|
||||
import { TourTrailGradeService } from './tour-trail-grade.service';
|
||||
import { TourVisitsController } from './tour-visits.controller';
|
||||
import { TourVisitsService } from './tour-visits.service';
|
||||
import { TourWalkImportController } from './tour-walk-import.controller';
|
||||
import { TourWalkImportMcp } from './tour-walk-import.mcp';
|
||||
import { TourWalkImportService } from './tour-walk-import.service';
|
||||
import { ToursImportController } from './tours-import.controller';
|
||||
import { ToursWandererController } from './tours-wanderer.controller';
|
||||
import { ToursWandererService } from './tours-wanderer.service';
|
||||
@@ -43,7 +46,9 @@ import { Module } from '@nestjs/common';
|
||||
* persists the places through PlacesService (PlacesModule). The tour SQL lives in
|
||||
* the Tours and TourWaypoints repositories; the owning place's route columns go
|
||||
* through PlacesRepository, registered here because forFeature only reaches
|
||||
* this module's own providers.
|
||||
* this module's own providers. A Google Maps walking route is read into the
|
||||
* stops of a city walk through PlaceImportModule as well (CW-07), its short
|
||||
* link followed by the narrow resolver there (CW-06).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -77,6 +82,7 @@ import { Module } from '@nestjs/common';
|
||||
TourPoiSearchController,
|
||||
TourTrailGradeController,
|
||||
TourVisitsController,
|
||||
TourWalkImportController,
|
||||
ToursController,
|
||||
ToursImportController,
|
||||
ToursWandererController,
|
||||
@@ -94,8 +100,10 @@ import { Module } from '@nestjs/common';
|
||||
TourPoiSearchService,
|
||||
TourTrailGradeService,
|
||||
TourVisitsService,
|
||||
TourWalkImportService,
|
||||
ToursMcp,
|
||||
TourLibraryMcp,
|
||||
TourWalkImportMcp,
|
||||
],
|
||||
})
|
||||
export class ToursModule {}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Tours e2e for the Google Maps walk import (CW-06, CW-07): POST
|
||||
* /api/trips/:tripId/tours/import/google-maps/preview through the real
|
||||
* AddonGuard, JwtAuthGuard and TripAccessGuard, the Zod pipe and the response
|
||||
* contract, against a migrated and seeded temp SQLite db. The short link
|
||||
* resolver is the real one; only the name lookup and the global fetch are
|
||||
* stubbed, so no request leaves the test and every one it would make is seen.
|
||||
*
|
||||
* Fixtures: user 1 owns trip 5, user 2 is a member whom the permission spy
|
||||
* denies 'place_edit', user 3 has no access to the trip.
|
||||
*/
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
import { Tours } from '../../src/db/entities/Tours.entity';
|
||||
import { Trips } from '../../src/db/entities/Trips.entity';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
|
||||
import { MapsService } from '../../src/nest/maps/maps.service';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
||||
import { ToursModule } from '../../src/nest/tours/tours.module';
|
||||
import { PORTO_RIBEIRA } from '../fixtures/google-maps-walks';
|
||||
import { createSnapshotTestDb } from '../helpers/db-mock';
|
||||
import { countRows, insertRows } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { addTripMember } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { FakeRealtimeService } from '../helpers/fake-realtime';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { sessionCookie } from './harness';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { WALK_IMPORT_URL_MAX } from '@trek/shared';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
import dns from 'dns/promises';
|
||||
import type { Server } from 'http';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi, type MockInstance } from 'vitest';
|
||||
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
vi.mock('dns/promises', () => ({ default: { lookup: vi.fn() }, lookup: vi.fn() }));
|
||||
|
||||
const { journeyHooks } = vi.hoisted(() => ({
|
||||
journeyHooks: {
|
||||
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
|
||||
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
// The map search is never asked: a stop without a position is not looked up.
|
||||
const maps = { geocodeQuery: vi.fn(), reverseGeocode: vi.fn(), searchPlaces: vi.fn(), poisAlong: vi.fn() };
|
||||
const realtime = new FakeRealtimeService();
|
||||
const lookup = vi.mocked(dns.lookup);
|
||||
|
||||
let orm: TestOrm;
|
||||
let checkPermission: MockInstance;
|
||||
|
||||
const OWNER = 1;
|
||||
const VIEWER = 2;
|
||||
const OUTSIDER = 3;
|
||||
const SHORT = 'https://maps.app.goo.gl/gwKHfs8TNxShEBBk7';
|
||||
const ROUTE = 'https://www.google.com/maps/dir/Cathedral/Market/@48.1,11.5,15z/data=';
|
||||
/** The requests one after another: supertest opens a listener of its own for each. */
|
||||
function inTurn<T, R>(items: readonly T[], run: (item: T) => Promise<R>): Promise<R[]> {
|
||||
return items.reduce<Promise<R[]>>(
|
||||
(previous, item) => previous.then(async (done) => [...done, await run(item)]),
|
||||
Promise.resolve([]),
|
||||
);
|
||||
}
|
||||
const BLOCKS = '!1m5!1m1!1s0x0:0x0!2m2!1d11.5!2d48.1!1m5!1m1!1s0x0:0x0!2m2!1d11.51!2d48.11';
|
||||
|
||||
describe('Tours walk import e2e (real guards + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
await TestUnitOfWorkModule.forRoot(db),
|
||||
await createTestMikroOrmModule(db),
|
||||
RealtimeModule,
|
||||
ToursModule,
|
||||
],
|
||||
})
|
||||
.overrideProvider(RealtimeService)
|
||||
.useValue(realtime)
|
||||
.overrideProvider(JourneyDomainService)
|
||||
.useValue(journeyHooks)
|
||||
.overrideProvider(MapsService)
|
||||
.useValue(maps)
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
const preview = (userId: number | null, body: unknown, tripId = 5) => {
|
||||
const req = request(server).post(`/api/trips/${tripId}/tours/import/google-maps/preview`);
|
||||
return (userId === null ? req : req.set('Cookie', sessionCookie(userId))).send(body as object);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(db);
|
||||
// Pinned ids: the session cookies sign for exactly these users.
|
||||
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
|
||||
await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 });
|
||||
await makeUser(orm, {
|
||||
id: 3,
|
||||
username: 'outsider',
|
||||
email: 'outsider@example.test',
|
||||
role: 'user',
|
||||
password_version: 0,
|
||||
});
|
||||
await insertRows(orm, Trips, [{ id: 5, user: 1, title: 'Porto' }]);
|
||||
await addTripMember(orm, 5, 2);
|
||||
app = await build();
|
||||
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
checkPermission.mockImplementation(
|
||||
(action: string, _role: string, _owner: number, userId: number) =>
|
||||
!(action === 'place_edit' && userId === VIEWER),
|
||||
);
|
||||
await setAddonEnabled(orm, 'tours', true);
|
||||
lookup.mockResolvedValue([{ address: '142.250.185.14', family: 4 }] as never);
|
||||
fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
for (const fn of Object.values(maps)) fn.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
lookup.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await orm.close();
|
||||
});
|
||||
|
||||
it('401 without a session, 404 outside the trip or while the addon is off, 403 without place_edit', async () => {
|
||||
const body = { url: PORTO_RIBEIRA };
|
||||
expect((await preview(null, body)).status).toBe(401);
|
||||
expect((await preview(OUTSIDER, body)).status).toBe(404);
|
||||
expect((await preview(VIEWER, body)).status).toBe(403);
|
||||
await setAddonEnabled(orm, 'tours', false);
|
||||
const off = await preview(OWNER, body);
|
||||
expect([off.status, off.body]).toEqual([404, { error: 'Tours addon is not enabled' }]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400 from the contract for an empty link, one longer than a link may be, or a field it does not know', async () => {
|
||||
const bodies = [{ url: ' ' }, { url: 'x'.repeat(WALK_IMPORT_URL_MAX + 1) }, { url: SHORT, mode: 'walk' }, {}];
|
||||
const answers = await inTurn(bodies, async (body) => await preview(OWNER, body));
|
||||
expect(answers.map((res) => res.status)).toEqual([400, 400, 400, 400]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('200 for a long walking link: its stops in order, with nothing asked of the network and nothing stored', async () => {
|
||||
const res = await preview(OWNER, { url: PORTO_RIBEIRA });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resolved_url).toBe(PORTO_RIBEIRA);
|
||||
expect(res.body.stations).toHaveLength(5);
|
||||
expect(res.body.stations[0]).toEqual({
|
||||
name: 'Igreja de Santa Clara, Largo Primeiro de Dezembro, 4000-404 Porto, Portugal',
|
||||
lat: 41.1425899,
|
||||
lng: -8.6092477,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
expect(await countRows(orm, Places, {})).toBe(0);
|
||||
expect(await countRows(orm, Tours, {})).toBe(0);
|
||||
expect(realtime.broadcastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400 with its reason for a car route, a link that does not say, no route and no Google Maps link', async () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
[`${ROUTE}!4m14!4m13${BLOCKS}!3e0`, 'not_walking'],
|
||||
[`${ROUTE}!4m13!4m12${BLOCKS}`, 'mode_unknown'],
|
||||
['https://www.google.com/maps/place/Cathedral/@48.1,11.5,17z', 'not_a_route'],
|
||||
['https://example.com/maps/dir/Cathedral/Market/data=!3e2', 'not_google_maps'],
|
||||
];
|
||||
const answers = await inTurn(cases, async ([url]) => await preview(OWNER, { url }));
|
||||
expect(answers.map((res) => [res.status, res.body.reason])).toEqual(cases.map(([, reason]) => [400, reason]));
|
||||
for (const res of answers) expect(typeof res.body.error).toBe('string');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
for (const fn of Object.values(maps)) expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('follows a short link one hop to Google Maps, and refuses one that leads elsewhere after that one request', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: PORTO_RIBEIRA } }));
|
||||
const res = await preview(OWNER, { url: SHORT });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.resolved_url).toBe(PORTO_RIBEIRA);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
fetchSpy.mockReset();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(null, { status: 302, headers: { location: 'https://example.com/maps/dir/A/B/data=!3e2' } }),
|
||||
);
|
||||
const elsewhere = await preview(OWNER, { url: SHORT });
|
||||
expect([elsewhere.status, elsewhere.body.reason]).toEqual([400, 'short_link_refused']);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('502 when the short link cannot be followed, and nothing stored', async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new TypeError('fetch failed'));
|
||||
const res = await preview(OWNER, { url: SHORT });
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body).toEqual({
|
||||
error: 'The short link could not be followed just now. Try again, or paste the long link.',
|
||||
reason: 'short_link_failed',
|
||||
});
|
||||
expect(await countRows(orm, Tours, {})).toBe(0);
|
||||
});
|
||||
});
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Real Google Maps walking routes (CW-07), as their short links answered on
|
||||
* 2026-10-20: each `https://maps.app.goo.gl/<code>` gave one 302 straight to
|
||||
* the www.google.com/maps/dir link below, with no consent page in between.
|
||||
* Four walks through Porto shared by a travel blog; the query Google appends
|
||||
* (entry, g_ep, skid) is left off. Every one ends in `!3e2`, on foot.
|
||||
*
|
||||
* Two of them are the hard case for reading positions: PORTO_ALIADOS drags its
|
||||
* line through a point of its own between two stops, and PORTO_CLERIGOS does
|
||||
* that and has two stops given as coordinates, so a count of the coordinate
|
||||
* pairs no longer matches the stops.
|
||||
*/
|
||||
/** https://maps.app.goo.gl/G8RFU9t5AbTx3QEZA */
|
||||
export const PORTO_CLERIGOS =
|
||||
'https://www.google.com/maps/dir/Igreja+de+Santo+Ant%C3%B3nio+dos+Congregados,+R.+de+S%C3%A1+da+Bande' +
|
||||
'ira+11,+4000-433+Porto,+Portugal/Torre+dos+Cl%C3%A9rigos,+R.+de+S%C3%A3o+Filipe+de+Nery,+4050-546+Po' +
|
||||
'rto,+Portugal/Livraria+Lello,+R.+das+Carmelitas+144,+4050-161+Porto,+Portugal/Igreja+do+Carmo,+R.+do' +
|
||||
'+Carmo,+4050-164+Porto,+Portugal/Restaurante+Carlos+Alberto,+Pra%C3%A7a+de+Carlos+Alberto+89,+4050-1' +
|
||||
'58+Porto,+Portugal/Miradouro+da+Vit%C3%B3ria,+R.+de+S%C3%A3o+Bento+da+Vit%C3%B3ria+11,+4050-265+Port' +
|
||||
'o,+Portugal/41.1442506,-8.6138221/Mercado+Ferreira+Borges,+R.+da+Bolsa+19,+4050-253+Porto,+Portugal/' +
|
||||
'Pal%C3%A1cio+da+Bolsa,+R.+de+Ferreira+Borges+11,+4050-253+Porto,+Portugal/41.1403376,-8.6145436/@41.' +
|
||||
'1443544,-8.6187204,1418m/data=!3m2!1e3!5s0xd2464e1c85c7cb9:0x8dcf9241515af39c!4m67!4m66!1m5!1m1!1s0x' +
|
||||
'd2464e386381dd9:0x71fe2c588c0e5d5c!2m2!1d-8.6107393!2d41.1461867!1m5!1m1!1s0xd2464e251ced649:0x425ec' +
|
||||
'51327127c21!2m2!1d-8.6145977!2d41.1456705!1m5!1m1!1s0xd2464e28c5e9a77:0x7f08405ee8a1e44f!2m2!1d-8.61' +
|
||||
'48718!2d41.1468104!1m5!1m1!1s0xd2464e2992d6fc9:0x14e81d436806274!2m2!1d-8.6162729!2d41.1473563!1m15!' +
|
||||
'1m1!1s0xd2464e2aef96389:0xfdd13b0f82024051!2m2!1d-8.6155251!2d41.1484332!3m4!1m2!1d-8.6175322!2d41.1' +
|
||||
'457355!3s0xd24651da106d14d:0x3fc8d2a99969c3c9!3m4!1m2!1d-8.6158595!2d41.1453503!3s0xd2464e271988c81:' +
|
||||
'0x9c8eada62d9eb19e!1m5!1m1!1s0xd2464e1fb67ccaf:0x83772eddb20da5!2m2!1d-8.6158324!2d41.1430829!1m5!3m' +
|
||||
'4!1m2!1d-8.6147197!2d41.1430656!3s0xd2464e1932b9b77:0x52ecdbda923433b2!1m5!1m1!1s0xd2464e1c90c863d:0' +
|
||||
'xbdf9a36d350cdbae!2m2!1d-8.615126!2d41.1419501!1m5!1m1!1s0xd2464e1c2820d41:0x69324171e40cd951!2m2!1d' +
|
||||
'-8.6156725!2d41.1413772!1m0!3e2';
|
||||
|
||||
/** https://maps.app.goo.gl/VigUDiFTi652ZJ58A */
|
||||
export const PORTO_ALIADOS =
|
||||
'https://www.google.com/maps/dir/Sculpture+Dom+Pedro+IV/Porto+City+Hall,+PC+GEN+Humberto+Delgado,+404' +
|
||||
'9-001+Porto,+Portugal/Igreja+da+Sant%C3%ADssima+Trindade,+Pra%C3%A7a+Trindade+170,+4000-539+Porto,+P' +
|
||||
'ortugal/Chapel+of+Souls,+R.+de+Santa+Catarina+428,+4000-124+Porto,+Portugal/Majestic+Caf%C3%A9,+R.+d' +
|
||||
'e+Santa+Catarina+112,+4000-442+Porto,+Portugal/Jardim+Marques+de+Oliveira,+Passeio+de+S%C3%A3o+L%C3%' +
|
||||
'A1zaro+33,+4000-507+Porto,+Portugal/Church+of+Saint+Ildefonso,+Pra%C3%A7a+da+Batalha+s%2Fn,+4000-101' +
|
||||
'+Porto,+Portugal/Pra%C3%A7a+da+Batalha,+R.+de+Augusto+Rosa,+4000-529+Porto,+Portugal/Porto+S%C3%A3o+' +
|
||||
'Bento,+Pra%C3%A7a+de+Almeida+Garrett,+4000-069+Porto,+Portugal/@41.145893,-8.6093059,709m/data=!3m2!' +
|
||||
'1e3!5s0xd2464fb7b6cb07b:0x89f066ff0f3967a4!4m61!4m60!1m5!1m1!1s0x0:0x3ef7f76b5b4253d6!2m2!1d-8.61157' +
|
||||
'72!2d41.1463491!1m5!1m1!1s0xd2464fca6339107:0x5dc6096016a67879!2m2!1d-8.6107023!2d41.1499084!1m5!1m1' +
|
||||
'!1s0xd2464fb615293c7:0x7c79e721d53d0f87!2m2!1d-8.6105681!2d41.1508419!1m5!1m1!1s0xd2464faae719e5f:0x' +
|
||||
'11125f1ca37787b5!2m2!1d-8.6056554!2d41.1498821!1m5!1m1!1s0xd2464e5a52a2493:0x1dc3afde71cae5f4!2m2!1d' +
|
||||
'-8.6065795!2d41.1472382!1m5!1m1!1s0xd2464ef2191b9e3:0xb0d8f9ac36dd3f2c!2m2!1d-8.602522!2d41.1459635!' +
|
||||
'1m5!1m1!1s0xd2464e5bc4a1885:0x1d8d4eb48388fdc7!2m2!1d-8.6066989!2d41.1460999!1m10!1m1!1s0xd2464e6783' +
|
||||
'e9b1f:0xb97ba181af122779!2m2!1d-8.6070394!2d41.1451574!3m4!1m2!1d-8.6082184!2d41.1461502!3s0xd2464e4' +
|
||||
'44e45d6b:0xf1247bcc87b60d1d!1m5!1m1!1s0xd2464e4756ff947:0x124f921e49a8773e!2m2!1d-8.609544!2d41.1454' +
|
||||
'92!3e2';
|
||||
|
||||
/** https://maps.app.goo.gl/gwKHfs8TNxShEBBk7 */
|
||||
export const PORTO_RIBEIRA =
|
||||
'https://www.google.com/maps/dir/Igreja+de+Santa+Clara,+Largo+Primeiro+de+Dezembro,+4000-404+Porto,+P' +
|
||||
'ortugal/Porto+Cathedral,+Terreiro+da+S%C3%A9,+4050-573+Porto,+Portugal/Mosteiro+Santo+Agostinho+da+S' +
|
||||
'erra+do+Pilar,+Largo+Aviz,+4430-329+Vila+Nova+de+Gaia,+Portugal/Mirador+de+Porto+y+Duero/Ribeira+de+' +
|
||||
'Gaia/@41.139645,-8.6121024,1418m/data=!3m2!1e3!5s0xd2464e7415940f5:0x9c2de86e34f7d338!4m32!4m31!1m5!' +
|
||||
'1m1!1s0xd2464e6c2a4163b:0xfde6dd68a1ab3e6c!2m2!1d-8.6092477!2d41.1425899!1m5!1m1!1s0xd2464e13991ebb9' +
|
||||
':0x4a1cccaea07537e9!2m2!1d-8.6111165!2d41.1428531!1m5!1m1!1s0xd2464ddc3346ee7:0xab7221d21110d0a3!2m2' +
|
||||
'!1d-8.6075262!2d41.138298!1m5!1m1!1s0xd24657223ef3713:0xc8700250c6991029!2m2!1d-8.609616!2d41.139145' +
|
||||
'2!1m5!1m1!1s0xd2464de113e580d:0x7e3c5edb72fc8aa9!2m2!1d-8.6117899!2d41.1382776!3e2';
|
||||
|
||||
/** https://maps.app.goo.gl/sePQGivnkMLuTUd86 */
|
||||
export const GAIA_CELLARS =
|
||||
'https://www.google.com/maps/dir/Mercado+Municipal+de+Gaia,+Largo+Sampaio+Bruno+10,+4400-161+Vila+Nov' +
|
||||
"a+de+Gaia,+Portugal/Cockburn's,+R.+de+Serpa+Pinto+346,+4400-307+Vila+Nova+de+Gaia,+Portugal/Croft+Po" +
|
||||
'rt/Arco+e+Meio+-+Portuguese+Wine+Shop,+Tv.+de+C%C3%A2ndido+dos+Reis+9,+4430-999+Vila+Nova+de+Gaia,+P' +
|
||||
'ortugal/@41.1352706,-8.620441,1418m/data=!3m2!1e3!4b1!4m26!4m25!1m5!1m1!1s0xd2465207829c837:0xa058d1' +
|
||||
'5855e35382!2m2!1d-8.6162726!2d41.1369384!1m5!1m1!1s0xd246520d2b824a3:0xe5019341caefb9df!2m2!1d-8.618' +
|
||||
'1478!2d41.133527!1m5!1m1!1s0xd246521b0dbd957:0x9a6db3995f97d21b!2m2!1d-8.615071!2d41.1349963!1m5!1m1' +
|
||||
'!1s0xd2464def5bfcab3:0xef35a43b60659b42!2m2!1d-8.6121984!2d41.1365006!3e2';
|
||||
@@ -2,6 +2,7 @@ import { PlaceImportService } from '../../src/nest/place-import/place-import.ser
|
||||
import { CollectionGpxProvider } from '../../src/nest/place-import/providers/collection-gpx.provider';
|
||||
import { GoogleDirectionsProvider } from '../../src/nest/place-import/providers/google-directions.provider';
|
||||
import { GoogleListProvider } from '../../src/nest/place-import/providers/google-list.provider';
|
||||
import { GoogleMapsShortLinkProvider } from '../../src/nest/place-import/providers/google-maps-short-link.provider';
|
||||
import { GpxProvider } from '../../src/nest/place-import/providers/gpx.provider';
|
||||
import { KmlProvider } from '../../src/nest/place-import/providers/kml.provider';
|
||||
import { NaverListProvider } from '../../src/nest/place-import/providers/naver-list.provider';
|
||||
@@ -18,5 +19,6 @@ export function buildPlaceImportService(): PlaceImportService {
|
||||
new GoogleListProvider(),
|
||||
new GoogleDirectionsProvider(),
|
||||
new NaverListProvider(),
|
||||
new GoogleMapsShortLinkProvider(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Which Google Maps links a pasted link may be (GMH-*): the host shape the
|
||||
* place and route imports check, and the narrower short and long link shapes
|
||||
* the city walk import follows and reads (CW-06).
|
||||
*/
|
||||
import {
|
||||
isGoogleMapsHost,
|
||||
isGoogleMapsLongLink,
|
||||
isGoogleMapsShortLink,
|
||||
} from '../../../../src/nest/common/google-maps-hosts';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const url = (raw: string) => new URL(raw);
|
||||
|
||||
describe('isGoogleMapsHost', () => {
|
||||
it('GMH-001: takes Google on every country domain and the short hosts, nothing that only looks like it', () => {
|
||||
for (const host of [
|
||||
'google.com',
|
||||
'www.google.de',
|
||||
'maps.google.co.uk',
|
||||
'google.com.au',
|
||||
'maps.app.goo.gl',
|
||||
'goo.gl',
|
||||
]) {
|
||||
expect(isGoogleMapsHost(host)).toBe(true);
|
||||
}
|
||||
for (const host of ['google.evil.com', 'evilgoogle.com', 'maps.google.com.evil.io', 'mail.google.com']) {
|
||||
expect(isGoogleMapsHost(host)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGoogleMapsShortLink (CW-06)', () => {
|
||||
it('GMH-010: follows a maps.app.goo.gl code and a goo.gl/maps code', () => {
|
||||
expect(isGoogleMapsShortLink(url('https://maps.app.goo.gl/gwKHfs8TNxShEBBk7'))).toBe(true);
|
||||
expect(isGoogleMapsShortLink(url('https://maps.app.goo.gl/AbC1/'))).toBe(true);
|
||||
expect(isGoogleMapsShortLink(url('https://goo.gl/maps/AbC1'))).toBe(true);
|
||||
// The default port is no port.
|
||||
expect(isGoogleMapsShortLink(url('https://maps.app.goo.gl:443/AbC1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('GMH-011: refuses plain http, credentials, another port, other paths and look-alike hosts', () => {
|
||||
for (const raw of [
|
||||
'http://maps.app.goo.gl/AbC1',
|
||||
'https://user:pw@maps.app.goo.gl/AbC1',
|
||||
'https://user@maps.app.goo.gl/AbC1',
|
||||
'https://maps.app.goo.gl:8443/AbC1',
|
||||
'https://maps.app.goo.gl/',
|
||||
'https://maps.app.goo.gl/a/b',
|
||||
'https://goo.gl/xyz',
|
||||
'https://goo.gl/maps/a/b',
|
||||
'https://maps.app.goo.gl.evil.com/AbC1',
|
||||
'https://www.google.com/maps/dir/A/B',
|
||||
]) {
|
||||
expect(isGoogleMapsShortLink(url(raw))).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGoogleMapsLongLink (CW-06)', () => {
|
||||
it('GMH-020: reads Google Maps on a country domain', () => {
|
||||
expect(isGoogleMapsLongLink(url('https://www.google.com/maps/dir/A/B'))).toBe(true);
|
||||
expect(isGoogleMapsLongLink(url('https://www.google.de/maps/dir/A/B'))).toBe(true);
|
||||
expect(isGoogleMapsLongLink(url('https://maps.google.co.uk/maps/dir/A/B'))).toBe(true);
|
||||
});
|
||||
|
||||
it('GMH-021: refuses another host, a short host, credentials, another port and plain http', () => {
|
||||
for (const raw of [
|
||||
'https://google.evil.com/maps/dir/A/B',
|
||||
'https://maps.app.goo.gl/AbC1',
|
||||
'https://goo.gl/maps/AbC1',
|
||||
'https://user:pw@www.google.com/maps/dir/A/B',
|
||||
'https://www.google.com:8443/maps/dir/A/B',
|
||||
'http://www.google.com/maps/dir/A/B',
|
||||
]) {
|
||||
expect(isGoogleMapsLongLink(url(raw))).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,12 @@
|
||||
* that matter, because the full ones run to four hundred characters of protobuf.
|
||||
*/
|
||||
import {
|
||||
directionsTravelMode,
|
||||
isDirectionsUrl,
|
||||
parseDirectionsUrl,
|
||||
MAX_DIR_WAYPOINTS,
|
||||
} from '../../../../src/nest/place-import/directions-url.helpers';
|
||||
import { GAIA_CELLARS, PORTO_ALIADOS, PORTO_CLERIGOS, PORTO_RIBEIRA } from '../../../fixtures/google-maps-walks';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
@@ -119,3 +121,143 @@ describe('parseDirectionsUrl', () => {
|
||||
expect(parseDirectionsUrl('https://www.google.com/maps/dir/%E0%A4%A/Dresden')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDirectionsUrl with stop blocks (CW-07)', () => {
|
||||
const blocks = (url: string) => parseDirectionsUrl(url, MAX_DIR_WAYPOINTS, 'stop-blocks');
|
||||
const placed = (url: string) => blocks(url).filter((stop) => stop.lat !== null).length;
|
||||
|
||||
it('MAPS-DIR-030: a real walk places every stop from its own block, in order and with its name', () => {
|
||||
expect(blocks(PORTO_RIBEIRA)).toEqual([
|
||||
{
|
||||
name: 'Igreja de Santa Clara, Largo Primeiro de Dezembro, 4000-404 Porto, Portugal',
|
||||
lat: 41.1425899,
|
||||
lng: -8.6092477,
|
||||
},
|
||||
{ name: 'Porto Cathedral, Terreiro da Sé, 4050-573 Porto, Portugal', lat: 41.1428531, lng: -8.6111165 },
|
||||
{
|
||||
name: 'Mosteiro Santo Agostinho da Serra do Pilar, Largo Aviz, 4430-329 Vila Nova de Gaia, Portugal',
|
||||
lat: 41.138298,
|
||||
lng: -8.6075262,
|
||||
},
|
||||
{ name: 'Mirador de Porto y Duero', lat: 41.1391452, lng: -8.609616 },
|
||||
{ name: 'Ribeira de Gaia', lat: 41.1382776, lng: -8.6117899 },
|
||||
]);
|
||||
// Where every stop has one pair, both readings agree.
|
||||
expect(parseDirectionsUrl(PORTO_RIBEIRA)).toEqual(blocks(PORTO_RIBEIRA));
|
||||
expect(placed(GAIA_CELLARS)).toBe(4);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-031: a point the line was dragged through is not taken for the stop it follows', () => {
|
||||
const stops = blocks(PORTO_ALIADOS);
|
||||
expect(stops).toHaveLength(9);
|
||||
expect(stops.every((stop) => stop.lat !== null)).toBe(true);
|
||||
// Praça da Batalha's own position, not the dragged point (41.1461502, -8.6082184) in its block.
|
||||
expect(stops[7]).toEqual({
|
||||
name: 'Praça da Batalha, R. de Augusto Rosa, 4000-529 Porto, Portugal',
|
||||
lat: 41.1451574,
|
||||
lng: -8.6070394,
|
||||
});
|
||||
// The pair count has one pair too many there, so the road trip reading places nothing.
|
||||
expect(parseDirectionsUrl(PORTO_ALIADOS).every((stop) => stop.lat === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-032: stops given as coordinates keep them, and the stops after them keep their own', () => {
|
||||
const stops = blocks(PORTO_CLERIGOS);
|
||||
expect(stops).toHaveLength(10);
|
||||
expect(stops.slice(5, 10)).toEqual([
|
||||
{
|
||||
name: 'Miradouro da Vitória, R. de São Bento da Vitória 11, 4050-265 Porto, Portugal',
|
||||
lat: 41.1430829,
|
||||
lng: -8.6158324,
|
||||
},
|
||||
{ name: null, lat: 41.1442506, lng: -8.6138221 },
|
||||
{ name: 'Mercado Ferreira Borges, R. da Bolsa 19, 4050-253 Porto, Portugal', lat: 41.1419501, lng: -8.615126 },
|
||||
{
|
||||
name: 'Palácio da Bolsa, R. de Ferreira Borges 11, 4050-253 Porto, Portugal',
|
||||
lat: 41.1413772,
|
||||
lng: -8.6156725,
|
||||
},
|
||||
{ name: null, lat: 41.1403376, lng: -8.6145436 },
|
||||
]);
|
||||
expect(placed(PORTO_CLERIGOS)).toBe(10);
|
||||
expect(parseDirectionsUrl(PORTO_CLERIGOS).filter((stop) => stop.lat !== null)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-033: a blob that does not pair up with the stops places none of them', () => {
|
||||
const route = 'https://www.google.com/maps/dir/Cathedral/Market/Park/';
|
||||
const block = (lng: number, lat: number) => `!1m5!1m1!1s0x0:0x0!2m2!1d${lng}!2d${lat}`;
|
||||
// Two blocks for three stops.
|
||||
expect(placed(`${route}data=!4m13!4m12${block(11.5, 48.1)}${block(11.51, 48.11)}`)).toBe(0);
|
||||
// A message that claims more than follows it, a token that is no field, a count that is no number.
|
||||
expect(placed(`${route}data=!4m30!4m29${block(11.5, 48.1)}${block(11.51, 48.11)}${block(11.52, 48.12)}`)).toBe(0);
|
||||
expect(placed(`${route}data=!4m19!4m18${block(11.5, 48.1)}${block(11.51, 48.11)}!zz${block(11.52, 48.12)}`)).toBe(
|
||||
0,
|
||||
);
|
||||
expect(placed(`${route}data=!4mX${block(11.5, 48.1)}${block(11.51, 48.11)}${block(11.52, 48.12)}`)).toBe(0);
|
||||
// No route message at all, and no blob.
|
||||
expect(placed(`${route}data=!3m1!1e3`)).toBe(0);
|
||||
expect(placed(route)).toBe(0);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-034: a stop whose block holds no position of its own, or one off the globe, stays without one', () => {
|
||||
const route = 'https://www.google.com/maps/dir/Cathedral/Market/Park/';
|
||||
const url = `${route}data=!4m14!4m13!1m0!1m5!1m1!1s0x0:0x0!2m2!1d11.51!2d48.11!1m5!1m1!1s0x0:0x0!2m2!1d11.5!2d99`;
|
||||
expect(blocks(url)).toEqual([
|
||||
{ name: 'Cathedral', lat: null, lng: null },
|
||||
{ name: 'Market', lat: 48.11, lng: 11.51 },
|
||||
{ name: 'Park', lat: null, lng: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('directionsTravelMode (CW-07)', () => {
|
||||
const dir = 'https://www.google.com/maps/dir/Cathedral/Market/@48.1,11.5,15z/';
|
||||
|
||||
it('MAPS-DIR-040: reads on foot from the data blob and from the documented parameter', () => {
|
||||
expect(directionsTravelMode(`${dir}data=!4m2!4m1!3e2`)).toBe('walking');
|
||||
expect(directionsTravelMode(`${dir}data=%214m2%214m1%213e2`)).toBe('walking');
|
||||
expect(
|
||||
directionsTravelMode('https://www.google.com/maps/dir/?api=1&origin=A&destination=B&travelmode=walking'),
|
||||
).toBe('walking');
|
||||
expect(
|
||||
directionsTravelMode('https://www.google.com/maps/dir/?api=1&origin=A&destination=B&travelmode=WALKING'),
|
||||
).toBe('walking');
|
||||
for (const walk of [PORTO_RIBEIRA, PORTO_ALIADOS, PORTO_CLERIGOS, GAIA_CELLARS]) {
|
||||
expect(directionsTravelMode(walk)).toBe('walking');
|
||||
}
|
||||
});
|
||||
|
||||
it('MAPS-DIR-041: tells a car, a bike and transit apart, and names any other mode as other', () => {
|
||||
expect(directionsTravelMode(`${dir}data=!3e0`)).toBe('driving');
|
||||
expect(directionsTravelMode(`${dir}data=!3e1`)).toBe('bicycling');
|
||||
expect(directionsTravelMode(`${dir}data=!3e3`)).toBe('transit');
|
||||
expect(directionsTravelMode(`${dir}data=!3e9`)).toBe('other');
|
||||
expect(directionsTravelMode('https://www.google.com/maps/dir/?api=1&destination=B&travelmode=driving')).toBe(
|
||||
'driving',
|
||||
);
|
||||
expect(directionsTravelMode('https://www.google.com/maps/dir/?api=1&destination=B&travelmode=bicycling')).toBe(
|
||||
'bicycling',
|
||||
);
|
||||
expect(directionsTravelMode('https://www.google.com/maps/dir/?api=1&destination=B&travelmode=transit')).toBe(
|
||||
'transit',
|
||||
);
|
||||
expect(directionsTravelMode('https://www.google.com/maps/dir/?api=1&destination=B&travelmode=two-wheeler')).toBe(
|
||||
'other',
|
||||
);
|
||||
});
|
||||
|
||||
it('MAPS-DIR-042: a link that says nothing, or two different things, is unknown', () => {
|
||||
expect(directionsTravelMode(dir)).toBe('unknown');
|
||||
expect(directionsTravelMode(`${dir}data=!4m2!4m1!1m0`)).toBe('unknown');
|
||||
expect(directionsTravelMode(`${dir}data=!3e2?travelmode=driving`)).toBe('unknown');
|
||||
expect(directionsTravelMode(`${dir}data=!3e2!3e0`)).toBe('unknown');
|
||||
// The same mode said twice is still one mode.
|
||||
expect(directionsTravelMode(`${dir}data=!3e2?travelmode=walking`)).toBe('walking');
|
||||
});
|
||||
|
||||
it('MAPS-DIR-043: !3e means the mode only in the data blob of a route', () => {
|
||||
expect(directionsTravelMode(`${dir}?pb=!3e2`)).toBe('unknown');
|
||||
expect(directionsTravelMode('https://www.google.com/maps/place/Cathedral/data=!3e2')).toBe('unknown');
|
||||
expect(directionsTravelMode('not a link')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* GoogleMapsShortLinkProvider (CW-06, SHORT-*): a Google Maps short link
|
||||
* followed for the city walk import. The real SSRF guard and the real pinned
|
||||
* dispatcher run; only the resolver and the global fetch are stubbed, so every
|
||||
* case can say which names were looked up and which requests went out.
|
||||
*/
|
||||
import {
|
||||
GoogleMapsShortLinkProvider,
|
||||
SHORT_LINK_LIMITS,
|
||||
} from '../../../../src/nest/place-import/providers/google-maps-short-link.provider';
|
||||
import { PORTO_RIBEIRA } from '../../../fixtures/google-maps-walks';
|
||||
|
||||
import dns from 'dns/promises';
|
||||
import { Agent } from 'undici';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('dns/promises', () => ({ default: { lookup: vi.fn() }, lookup: vi.fn() }));
|
||||
|
||||
const lookup = vi.mocked(dns.lookup);
|
||||
const SHORT = 'https://maps.app.goo.gl/gwKHfs8TNxShEBBk7';
|
||||
const PUBLIC_IP = '142.250.185.14';
|
||||
|
||||
/** Every name resolves to the address its entry gives, a public one otherwise. */
|
||||
function resolveAs(addresses: Record<string, string> = {}) {
|
||||
lookup.mockImplementation((async (hostname: string) => [
|
||||
{ address: addresses[hostname] ?? PUBLIC_IP, family: 4 },
|
||||
]) as unknown as typeof dns.lookup);
|
||||
}
|
||||
|
||||
function redirect(location: string, status = 302) {
|
||||
return new Response(null, { status, headers: { location } });
|
||||
}
|
||||
|
||||
/** The answers in order, one per request. */
|
||||
function answers(...responses: Array<Response | object>) {
|
||||
const fetchSpy = vi.fn();
|
||||
for (const response of responses) fetchSpy.mockResolvedValueOnce(response);
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
return fetchSpy;
|
||||
}
|
||||
|
||||
const lookedUp = () => lookup.mock.calls.map((call) => call[0]);
|
||||
|
||||
/** The cases one after another, each settled before the next starts, as the stubs are set per case. */
|
||||
function inTurn<T>(items: readonly T[], run: (item: T) => Promise<void>): Promise<void> {
|
||||
return items.reduce<Promise<void>>((previous, item) => previous.then(() => run(item)), Promise.resolve());
|
||||
}
|
||||
const provider = new GoogleMapsShortLinkProvider();
|
||||
|
||||
beforeEach(() => resolveAs());
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
lookup.mockReset();
|
||||
});
|
||||
|
||||
describe('GoogleMapsShortLinkProvider', () => {
|
||||
it('SHORT-001: one hop to a Google Maps route is the answer, and the route itself is never asked', async () => {
|
||||
const fetchSpy = answers(redirect(PORTO_RIBEIRA));
|
||||
expect(await provider.resolve(SHORT)).toEqual({ ok: true, url: PORTO_RIBEIRA });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy.mock.calls[0][0]).toBe(SHORT);
|
||||
expect(lookedUp()).toEqual(['maps.app.goo.gl']);
|
||||
});
|
||||
|
||||
it('SHORT-002: a hop that leaves the allow-list is refused before it is looked up or asked', async () => {
|
||||
const locations = [
|
||||
'https://evil.example/maps/dir/A/B',
|
||||
'http://169.254.169.254/latest/meta-data/',
|
||||
'https://www.google.com.evil.io/maps/dir/A/B',
|
||||
'https://user:pw@www.google.com/maps/dir/A/B',
|
||||
'https://goo.gl/xyz',
|
||||
];
|
||||
await inTurn(locations, async (location) => {
|
||||
lookup.mockClear();
|
||||
const fetchSpy = answers(redirect(location));
|
||||
expect(await provider.resolve(SHORT)).toEqual({ ok: false, why: 'refused' });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(lookedUp()).toEqual(['maps.app.goo.gl']);
|
||||
});
|
||||
});
|
||||
|
||||
it('SHORT-003: a short link whose name leads into this network is refused at that hop', async () => {
|
||||
await inTurn(['127.0.0.1', '10.0.0.1', '169.254.169.254'], async (address) => {
|
||||
resolveAs({ 'goo.gl': address });
|
||||
const fetchSpy = answers(redirect('https://goo.gl/maps/next'));
|
||||
expect(await provider.resolve(SHORT)).toEqual({ ok: false, why: 'refused' });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// The first hop itself, too.
|
||||
resolveAs({ 'maps.app.goo.gl': '192.168.1.5' });
|
||||
const fetchSpy = answers();
|
||||
expect(await provider.resolve(SHORT)).toEqual({ ok: false, why: 'refused' });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SHORT-004: ALLOW_INTERNAL_NETWORK and ALLOW_LINK_LOCAL_IPS do not open it', async () => {
|
||||
vi.stubEnv('ALLOW_INTERNAL_NETWORK', 'true');
|
||||
vi.stubEnv('ALLOW_LINK_LOCAL_IPS', '169.254.1.2');
|
||||
vi.resetModules();
|
||||
const fresh = await import('../../../../src/nest/place-import/providers/google-maps-short-link.provider');
|
||||
const freshLookup = vi.mocked((await import('dns/promises')).default.lookup);
|
||||
const strict = new fresh.GoogleMapsShortLinkProvider();
|
||||
const fetchSpy = answers();
|
||||
await inTurn(['10.0.0.1', '169.254.1.2', 'fd00::1'], async (address) => {
|
||||
freshLookup.mockResolvedValue([{ address, family: address.includes(':') ? 6 : 4 }] as never);
|
||||
expect(await strict.resolve(SHORT)).toEqual({ ok: false, why: 'refused' });
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SHORT-005: three hops at most', async () => {
|
||||
const fetchSpy = answers(
|
||||
redirect('https://maps.app.goo.gl/two'),
|
||||
redirect('https://maps.app.goo.gl/three'),
|
||||
redirect('https://maps.app.goo.gl/four'),
|
||||
);
|
||||
expect(await provider.resolve(SHORT)).toEqual({ ok: false, why: 'too_many_redirects' });
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(SHORT_LINK_LIMITS.hops);
|
||||
|
||||
// Within the limit a short link may lead on to another one.
|
||||
answers(redirect('https://goo.gl/maps/next', 301), redirect(PORTO_RIBEIRA, 307));
|
||||
expect(await provider.resolve(SHORT)).toEqual({ ok: true, url: PORTO_RIBEIRA });
|
||||
});
|
||||
|
||||
it('SHORT-006: one deadline over the whole chain, a name lookup that hangs included', async () => {
|
||||
const hanging = vi.fn(
|
||||
(_url: string, init: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () => reject(init.signal?.reason));
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', hanging);
|
||||
expect(await provider.resolve(SHORT, { ...SHORT_LINK_LIMITS, deadlineMs: 20 })).toEqual({
|
||||
ok: false,
|
||||
why: 'unreachable',
|
||||
});
|
||||
expect(hanging).toHaveBeenCalledTimes(1);
|
||||
|
||||
lookup.mockImplementation((() => new Promise(() => undefined)) as unknown as typeof dns.lookup);
|
||||
const fetchSpy = answers();
|
||||
expect(await provider.resolve(SHORT, { ...SHORT_LINK_LIMITS, deadlineMs: 20 })).toEqual({
|
||||
ok: false,
|
||||
why: 'unreachable',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SHORT-007: a hop that answers after the deadline is not followed further', async () => {
|
||||
resolveAs();
|
||||
const late = vi.fn(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve(redirect('https://maps.app.goo.gl/next')), 40)),
|
||||
);
|
||||
vi.stubGlobal('fetch', late);
|
||||
expect(await provider.resolve(SHORT, { ...SHORT_LINK_LIMITS, deadlineMs: 10 })).toEqual({
|
||||
ok: false,
|
||||
why: 'unreachable',
|
||||
});
|
||||
expect(late).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SHORT-008: no body is read, and a Location longer than a link is refused', async () => {
|
||||
const cancel = vi.fn().mockResolvedValue(undefined);
|
||||
const getReader = vi.fn();
|
||||
const huge = {
|
||||
status: 302,
|
||||
headers: new Headers({
|
||||
location: `https://www.google.com/maps/dir/${'A/'.repeat(SHORT_LINK_LIMITS.locationChars)}`,
|
||||
'content-length': String(10 ** 9),
|
||||
}),
|
||||
body: { cancel, getReader },
|
||||
};
|
||||
answers(huge);
|
||||
expect(await provider.resolve(SHORT)).toEqual({ ok: false, why: 'unreachable' });
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
expect(getReader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SHORT-009: nothing of the caller goes along, and nothing is followed by fetch itself', async () => {
|
||||
const fetchSpy = answers(redirect(PORTO_RIBEIRA));
|
||||
await provider.resolve(SHORT);
|
||||
const init = fetchSpy.mock.calls[0][1] as RequestInit & { dispatcher?: unknown };
|
||||
expect(init).toMatchObject({
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
credentials: 'omit',
|
||||
referrerPolicy: 'no-referrer',
|
||||
});
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(init.dispatcher).toBeDefined();
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.has('cookie')).toBe(false);
|
||||
expect(headers.has('authorization')).toBe(false);
|
||||
});
|
||||
|
||||
it('SHORT-010: an answer that is no redirect, and a redirect relative to the short host', async () => {
|
||||
answers(new Response('not found', { status: 404 }));
|
||||
expect(await provider.resolve(SHORT)).toEqual({ ok: false, why: 'no_redirect' });
|
||||
answers(redirect('/elsewhere'), redirect(PORTO_RIBEIRA));
|
||||
expect(await provider.resolve(SHORT)).toEqual({ ok: true, url: PORTO_RIBEIRA });
|
||||
// A 3xx without a Location leads nowhere.
|
||||
answers(new Response(null, { status: 302 }));
|
||||
expect(await provider.resolve(SHORT)).toEqual({ ok: false, why: 'no_redirect' });
|
||||
});
|
||||
|
||||
it('SHORT-011: a start that is no short link, or no link, is refused without a request', async () => {
|
||||
const fetchSpy = answers();
|
||||
const answered = await Promise.all(
|
||||
['not a link', 'https://www.google.com/maps/dir/A/B', 'https://bit.ly/abc'].map((raw) => provider.resolve(raw)),
|
||||
);
|
||||
expect(answered).toEqual(Array(3).fill({ ok: false, why: 'refused' }));
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SHORT-012: a Location that is no link at all is refused', async () => {
|
||||
answers(redirect('https://['));
|
||||
expect(await provider.resolve(SHORT)).toEqual({ ok: false, why: 'refused' });
|
||||
});
|
||||
|
||||
it('SHORT-013: a request that fails is unreachable, and a dispatcher that will not close changes no answer', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue('socket hang up'));
|
||||
expect(await provider.resolve(SHORT)).toEqual({ ok: false, why: 'unreachable' });
|
||||
|
||||
const close = vi.spyOn(Agent.prototype, 'close').mockRejectedValueOnce(new Error('busy') as never);
|
||||
answers(redirect(PORTO_RIBEIRA));
|
||||
expect(await provider.resolve(SHORT)).toEqual({ ok: true, url: PORTO_RIBEIRA });
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
close.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createGpxParser,
|
||||
writeGpx,
|
||||
} from '../../../../src/nest/place-import/gpx.codec';
|
||||
import { GoogleMapsShortLinkProvider } from '../../../../src/nest/place-import/providers/google-maps-short-link.provider';
|
||||
import { buildPlaceImportService } from '../../../helpers/place-import';
|
||||
import { COLLECTION_FILE_FORMAT, COLLECTION_FILE_VERSION } from '@trek/shared';
|
||||
|
||||
@@ -69,6 +70,20 @@ describe('PlaceImportService: directions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceImportService: Google Maps short links (CW-06)', () => {
|
||||
it('hands a short link to its own narrow resolver and its answer back as it stands', async () => {
|
||||
const resolve = vi
|
||||
.spyOn(GoogleMapsShortLinkProvider.prototype, 'resolve')
|
||||
.mockResolvedValue({ ok: false, why: 'too_many_redirects' });
|
||||
expect(await buildPlaceImportService().resolveGoogleShortLink('https://maps.app.goo.gl/abc')).toEqual({
|
||||
ok: false,
|
||||
why: 'too_many_redirects',
|
||||
});
|
||||
expect(resolve).toHaveBeenCalledWith('https://maps.app.goo.gl/abc');
|
||||
resolve.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceImportService: files and lists', () => {
|
||||
const svc = buildPlaceImportService();
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* TourWalkImportController (CW-07): the route metadata of the Google Maps
|
||||
* walk preview and its delegation to TourWalkImportService.
|
||||
*/
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
|
||||
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
|
||||
import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
|
||||
import { TourWalkImportController } from '../../../src/nest/tours/tour-walk-import.controller';
|
||||
import type { TourWalkImportService } from '../../../src/nest/tours/tour-walk-import.service';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
||||
import type { WalkImportPreviewResponse } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const answer: WalkImportPreviewResponse = {
|
||||
stations: [
|
||||
{ name: 'Cathedral', lat: 48.1, lng: 11.5 },
|
||||
{ name: 'Market', lat: null, lng: null },
|
||||
],
|
||||
resolved_url: 'https://www.google.com/maps/dir/Cathedral/Market/data=!3e2',
|
||||
};
|
||||
|
||||
describe('TourWalkImportController', () => {
|
||||
it('WALK-IMPORT-CTL-001: sits behind the addon guard before auth and trip access, and needs place_edit', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, TourWalkImportController)).toBe('api/trips/:tripId/tours');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, TourWalkImportController)).toEqual([
|
||||
AddonGuard,
|
||||
JwtAuthGuard,
|
||||
TripAccessGuard,
|
||||
]);
|
||||
expect(Reflect.getMetadata(REQUIRE_ADDON, TourWalkImportController)).toEqual({ addonId: 'tours', label: 'Tours' });
|
||||
const handler = TourWalkImportController.prototype.preview;
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('import/google-maps/preview');
|
||||
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handler)).toBe(200);
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBe('place_edit');
|
||||
});
|
||||
|
||||
it('WALK-IMPORT-CTL-002: hands the link to the service and answers what it answers', async () => {
|
||||
const service = { preview: vi.fn(async () => answer) };
|
||||
const controller = new TourWalkImportController(service as unknown as TourWalkImportService);
|
||||
expect(await controller.preview({ url: 'https://maps.app.goo.gl/abc' })).toBe(answer);
|
||||
expect(service.preview).toHaveBeenCalledExactlyOnceWith('https://maps.app.goo.gl/abc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* TourWalkImportMcp (CW-07, WALK-IMPORT-MCP-*): the Google Maps walk preview
|
||||
* over MCP, with the same service and checks as REST. The subject comes from
|
||||
* the container over a snapshot test db; the refusal text comes back through
|
||||
* the registry's error mapper, as a client sees it.
|
||||
*/
|
||||
import { trekMcpErrorMapper } from '../../../src/mcp/nest-mcp-policy';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { DomainError, catchDomainError } from '../../../src/nest/common/domain-error';
|
||||
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
|
||||
import { GoogleMapsShortLinkProvider } from '../../../src/nest/place-import/providers/google-maps-short-link.provider';
|
||||
import { TourWalkImportMcp } from '../../../src/nest/tours/tour-walk-import.mcp';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { PORTO_RIBEIRA } from '../../fixtures/google-maps-walks';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createTrip, createUser } from '../../helpers/factories';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const resolve = vi.fn();
|
||||
let t: TestModule;
|
||||
let mcp: TourWalkImportMcp;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [ToursModule],
|
||||
overrides: [{ provide: GoogleMapsShortLinkProvider, useValue: { resolve } }],
|
||||
});
|
||||
mcp = t.get(TourWalkImportMcp);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
resolve.mockReset();
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const { user } = createUser(db);
|
||||
const { user: outsider } = createUser(db);
|
||||
const trip = createTrip(db, user.id, { title: 'Porto' });
|
||||
return { user, outsider, trip };
|
||||
}
|
||||
|
||||
describe('TourWalkImportMcp', () => {
|
||||
it('WALK-IMPORT-MCP-001: preview_google_maps_walk reads the stops of a walking route and stores nothing', async () => {
|
||||
const { user, trip } = fixture();
|
||||
const answer = read(await mcp.previewGoogleMapsWalk({ tripId: trip.id, url: PORTO_RIBEIRA }, ctx(user.id)));
|
||||
expect(answer.resolved_url).toBe(PORTO_RIBEIRA);
|
||||
expect(answer.stations).toHaveLength(5);
|
||||
expect(answer.stations[3]).toEqual({ name: 'Mirador de Porto y Duero', lat: 41.1391452, lng: -8.609616 });
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WALK-IMPORT-MCP-002: nothing for someone outside the trip, nor for a member who may not edit places', async () => {
|
||||
const { user, outsider, trip } = fixture();
|
||||
expect((await mcp.previewGoogleMapsWalk({ tripId: trip.id, url: PORTO_RIBEIRA }, ctx(outsider.id))).isError).toBe(
|
||||
true,
|
||||
);
|
||||
const denied = vi.spyOn(t.get(McpToolGuardsService), 'hasTripPermission').mockResolvedValue(false);
|
||||
const result = await mcp.previewGoogleMapsWalk({ tripId: trip.id, url: PORTO_RIBEIRA }, ctx(user.id));
|
||||
expect(result.isError).toBe(true);
|
||||
expect(denied).toHaveBeenCalledWith('place_edit', trip.id, user.id);
|
||||
denied.mockRestore();
|
||||
});
|
||||
|
||||
it('WALK-IMPORT-MCP-003: a route not on foot is refused with the English text REST sends', async () => {
|
||||
const { user, trip } = fixture();
|
||||
const car = PORTO_RIBEIRA.replace(/!3e2$/, '!3e0');
|
||||
const err = await catchDomainError(() => mcp.previewGoogleMapsWalk({ tripId: trip.id, url: car }, ctx(user.id)));
|
||||
expect(err).toBeInstanceOf(DomainError);
|
||||
expect(trekMcpErrorMapper(err)).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'That route is not planned on foot. Switch Google Maps to walking and share the route again.',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* TourWalkImportService (CW-07, WALK-IMPORT-*): a Google Maps walking route
|
||||
* read into the stops of a city walk. The short link resolver and the map
|
||||
* search are stubs: the first so no request leaves the test, the second to
|
||||
* show it is never asked, since a stop without a position is not guessed.
|
||||
*/
|
||||
import { DomainError, catchDomainError } from '../../../src/nest/common/domain-error';
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import { GoogleMapsShortLinkProvider } from '../../../src/nest/place-import/providers/google-maps-short-link.provider';
|
||||
import { TourWalkImportService } from '../../../src/nest/tours/tour-walk-import.service';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { PORTO_ALIADOS, PORTO_RIBEIRA } from '../../fixtures/google-maps-walks';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { TOUR_STATION_NAME_MAX, WALK_IMPORT_STATIONS_MAX, type WalkImportRefusal } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const resolve = vi.fn();
|
||||
const maps = { geocodeQuery: vi.fn(), reverseGeocode: vi.fn(), searchPlaces: vi.fn() };
|
||||
let t: TestModule;
|
||||
let service: TourWalkImportService;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [ToursModule],
|
||||
overrides: [
|
||||
{ provide: GoogleMapsShortLinkProvider, useValue: { resolve } },
|
||||
{ provide: MapsService, useValue: maps },
|
||||
],
|
||||
});
|
||||
service = t.get(TourWalkImportService);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resolve.mockReset();
|
||||
for (const fn of Object.values(maps)) fn.mockReset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const DIR = 'https://www.google.com/maps/dir/';
|
||||
const block = (lng: number, lat: number) => `!1m5!1m1!1s0x0:0x0!2m2!1d${lng}!2d${lat}`;
|
||||
/** A walking route through the stops named, each with its position in the blob. */
|
||||
function walk(names: string[], mode = '!3e2'): string {
|
||||
const blocks = names.map((_, i) => block(11.5 + i / 100, 48.1 + i / 100)).join('');
|
||||
const inner = names.length * 6 + (mode ? 1 : 0);
|
||||
return `${DIR}${names.map(encodeURIComponent).join('/')}/@48.1,11.5,15z/data=!4m${inner + 1}!4m${inner}${blocks}${mode}`;
|
||||
}
|
||||
|
||||
async function refusal(raw: string): Promise<{ status: number; reason: WalkImportRefusal; error: string }> {
|
||||
const err = await catchDomainError(() => service.preview(raw));
|
||||
if (!(err instanceof DomainError)) throw new Error('expected a refusal');
|
||||
return { status: err.getStatus(), reason: err.details?.reason as WalkImportRefusal, error: err.publicMessage };
|
||||
}
|
||||
|
||||
describe('TourWalkImportService.preview', () => {
|
||||
it('WALK-IMPORT-001: a long walking link answers its stops in order and the link it read, with no request made', async () => {
|
||||
const answer = await service.preview(` ${PORTO_RIBEIRA} `);
|
||||
expect(answer.resolved_url).toBe(PORTO_RIBEIRA);
|
||||
expect(answer.stations.map((station) => station.name)).toEqual([
|
||||
'Igreja de Santa Clara, Largo Primeiro de Dezembro, 4000-404 Porto, Portugal',
|
||||
'Porto Cathedral, Terreiro da Sé, 4050-573 Porto, Portugal',
|
||||
'Mosteiro Santo Agostinho da Serra do Pilar, Largo Aviz, 4430-329 Vila Nova de Gaia, Portugal',
|
||||
'Mirador de Porto y Duero',
|
||||
'Ribeira de Gaia',
|
||||
]);
|
||||
expect(answer.stations[0]).toMatchObject({ lat: 41.1425899, lng: -8.6092477 });
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
// A line dragged through a point of its own still places each stop on its own position.
|
||||
expect((await service.preview(PORTO_ALIADOS)).stations.every((station) => station.lat !== null)).toBe(true);
|
||||
});
|
||||
|
||||
it('WALK-IMPORT-002: a short link is read where the narrow resolver says it leads', async () => {
|
||||
resolve.mockResolvedValue({ ok: true, url: PORTO_RIBEIRA });
|
||||
const answer = await service.preview('https://maps.app.goo.gl/gwKHfs8TNxShEBBk7');
|
||||
expect(resolve).toHaveBeenCalledExactlyOnceWith('https://maps.app.goo.gl/gwKHfs8TNxShEBBk7');
|
||||
expect(answer.resolved_url).toBe(PORTO_RIBEIRA);
|
||||
expect(answer.stations).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('WALK-IMPORT-003: names are trimmed and cut to a station name, and a stop given as coordinates has none', async () => {
|
||||
const long = 'Museum '.repeat(40);
|
||||
const answer = await service.preview(`${DIR}${encodeURIComponent(` ${long}`)}/48.12,11.52/data=!3e2`);
|
||||
expect(answer.stations[0].name).toBe(long.trim().slice(0, TOUR_STATION_NAME_MAX).trim());
|
||||
expect(answer.stations[0].name?.length).toBeLessThanOrEqual(TOUR_STATION_NAME_MAX);
|
||||
expect(answer.stations[1]).toEqual({ name: null, lat: 48.12, lng: 11.52 });
|
||||
});
|
||||
|
||||
it('WALK-IMPORT-004: a stop the link gives no position stays without one, and nothing is looked up', async () => {
|
||||
// Three stops, two blocks: nothing lines up, so no stop takes a position from the blob.
|
||||
const url = `${DIR}Cathedral/Market/Park/data=!4m14!4m13${block(11.5, 48.1)}${block(11.51, 48.11)}!3e2`;
|
||||
const answer = await service.preview(url);
|
||||
expect(answer.stations).toEqual([
|
||||
{ name: 'Cathedral', lat: null, lng: null },
|
||||
{ name: 'Market', lat: null, lng: null },
|
||||
{ name: 'Park', lat: null, lng: null },
|
||||
]);
|
||||
for (const fn of Object.values(maps)) expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WALK-IMPORT-005: a car, a bike, transit or a link that does not say is refused with its reason', async () => {
|
||||
expect(await refusal(walk(['A', 'B'], '!3e0'))).toMatchObject({ status: 400, reason: 'not_walking' });
|
||||
expect(await refusal(walk(['A', 'B'], '!3e1'))).toMatchObject({ status: 400, reason: 'not_walking' });
|
||||
expect(await refusal(walk(['A', 'B'], '!3e3'))).toMatchObject({ status: 400, reason: 'not_walking' });
|
||||
expect(await refusal(walk(['A', 'B'], ''))).toMatchObject({ status: 400, reason: 'mode_unknown' });
|
||||
expect(await refusal(`${walk(['A', 'B'])}?travelmode=driving`)).toEqual({
|
||||
status: 400,
|
||||
reason: 'mode_unknown',
|
||||
error:
|
||||
'The link does not say clearly that the route is on foot. Choose walking in Google Maps and share it again.',
|
||||
});
|
||||
});
|
||||
|
||||
it('WALK-IMPORT-006: a link that is no Google Maps route is refused for what it is', async () => {
|
||||
expect(await refusal('not a link')).toMatchObject({ status: 400, reason: 'not_google_maps' });
|
||||
expect(await refusal('https://example.com/maps/dir/A/B/data=!3e2')).toMatchObject({ reason: 'not_google_maps' });
|
||||
expect(await refusal('https://www.google.evil.io/maps/dir/A/B/data=!3e2')).toMatchObject({
|
||||
reason: 'not_google_maps',
|
||||
});
|
||||
expect(await refusal('https://bit.ly/walk')).toMatchObject({ reason: 'not_google_maps' });
|
||||
expect(await refusal('https://www.google.com/maps/place/Cathedral/@48.1,11.5,17z')).toMatchObject({
|
||||
status: 400,
|
||||
reason: 'not_a_route',
|
||||
});
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WALK-IMPORT-007: a short link the resolver refuses or cannot follow says which, the second as Google’s failure', async () => {
|
||||
resolve.mockResolvedValueOnce({ ok: false, why: 'refused' });
|
||||
expect(await refusal('https://maps.app.goo.gl/abc')).toMatchObject({ status: 400, reason: 'short_link_refused' });
|
||||
// Each one Google's failure, not the link's.
|
||||
const failed = await Promise.all(
|
||||
(['unreachable', 'too_many_redirects', 'no_redirect'] as const).map((why) => {
|
||||
resolve.mockResolvedValueOnce({ ok: false, why });
|
||||
return refusal('https://maps.app.goo.gl/abc');
|
||||
}),
|
||||
);
|
||||
expect(failed.map(({ status, reason }) => [status, reason])).toEqual(Array(3).fill([502, 'short_link_failed']));
|
||||
});
|
||||
|
||||
it('WALK-IMPORT-008: fewer than two stops, or more than a walk takes, are refused', async () => {
|
||||
expect(await refusal(`${DIR}Cathedral/data=!3e2`)).toMatchObject({ status: 400, reason: 'too_few_stops' });
|
||||
const many = Array.from({ length: WALK_IMPORT_STATIONS_MAX + 1 }, (_, i) => `Stop${i}`);
|
||||
expect(await refusal(walk(many))).toMatchObject({ status: 400, reason: 'too_many_stops' });
|
||||
const most = await service.preview(walk(many.slice(0, WALK_IMPORT_STATIONS_MAX)));
|
||||
expect(most.stations).toHaveLength(WALK_IMPORT_STATIONS_MAX);
|
||||
expect(most.stations.every((station) => station.lat !== null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -108,7 +108,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'تخطيط جولة',
|
||||
'tours.planner.newTour': 'تخطيط جولة جديدة',
|
||||
'tours.planner.addTour': 'إضافة جولة',
|
||||
'tours.planner.addTourHint': 'خطط لجولة جديدة أو استورد ملفات GPX أو انقل جولة من رحلة أخرى',
|
||||
'tours.planner.addTourHint':
|
||||
'خطط لجولة جديدة أو استورد ملفات GPX أو جولة مشي من Google Maps أو انقل جولة من رحلة أخرى',
|
||||
'tours.planner.neutralTitle': 'تخطيط جولة',
|
||||
'tours.planner.neutralBody': 'ابدأ جولة جديدة وانقر على الخريطة لتحديد نقاط المسار.',
|
||||
'tours.planner.newTitle': 'جولة جديدة',
|
||||
@@ -319,6 +320,45 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'نقل',
|
||||
'tours.library.taken': 'تم نقل «{name}» إلى هذه الرحلة.',
|
||||
'tours.library.failed': 'تعذّر نقل الجولة.',
|
||||
'tours.walkImport.menu': 'استيراد جولة مشي من Google Maps',
|
||||
'tours.walkImport.title': 'جولة مشي من رابط Google Maps',
|
||||
'tours.walkImport.intro':
|
||||
'شارك مسار مشي في Google Maps والصق رابطه هنا. يقرأ TREK المحطات ويخطط بنفسه طريق المشي بينها.',
|
||||
'tours.walkImport.urlLabel': 'رابط مسار المشي',
|
||||
'tours.walkImport.urlHint': 'رابط مختصر من maps.app.goo.gl أو عنوان المسار على google.com/maps.',
|
||||
'tours.walkImport.read': 'قراءة الرابط',
|
||||
'tours.walkImport.back': 'استخدام رابط آخر',
|
||||
'tours.walkImport.load': 'تحميل كجولة مشي',
|
||||
'tours.walkImport.stops': 'المحطات في الرابط',
|
||||
'tours.walkImport.source': 'من Google Maps',
|
||||
'tours.walkImport.openSource': 'فتح في Google Maps',
|
||||
'tours.walkImport.recalculated': 'يخطط TREK الطريق بين المحطات من جديد، ولا يأخذ الخط المرسوم في Google Maps.',
|
||||
'tours.walkImport.noPosition': 'لا موقع في الرابط',
|
||||
'tours.walkImport.unresolved': '{count} محطة بلا موقع في الرابط ولذلك تُترك.',
|
||||
'tours.walkImport.unresolved.zero': 'لا توجد محطة بلا موقع في الرابط.',
|
||||
'tours.walkImport.unresolved.one': 'محطة واحدة بلا موقع في الرابط ولذلك تُترك.',
|
||||
'tours.walkImport.unresolved.two': 'محطتان بلا موقع في الرابط ولذلك تُتركان.',
|
||||
'tours.walkImport.unresolved.few': '{count} محطات بلا موقع في الرابط ولذلك تُترك.',
|
||||
'tours.walkImport.unresolved.many': '{count} محطة بلا موقع في الرابط ولذلك تُترك.',
|
||||
'tours.walkImport.tooFewPositions': 'تحتاج جولة المشي إلى محطتين على الأقل لهما موقع.',
|
||||
'tours.walkImport.replacesDraft': 'تحميل الجولة يستبدل التغييرات غير المحفوظة في المخطِّط.',
|
||||
'tours.walkImport.defaultName': 'جولة مشي من Google Maps',
|
||||
'tours.walkImport.draftNotice': 'من مسار مشي في Google Maps. خطط TREK بنفسه الطريق بين المحطات.',
|
||||
'tours.walkImport.offline': 'تتطلب قراءة الرابط اتصالًا بالإنترنت.',
|
||||
'tours.walkImport.failed': 'تعذرت قراءة الرابط الآن. حاول مرة أخرى بعد قليل.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'استخدم رابط مسار من Google Maps: رابطًا مختصرًا من maps.app.goo.gl أو رابطًا على google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'رابط Google Maps هذا ليس مسارًا. خطط جولة المشي في Google Maps وشارك المسار.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'هذا المسار غير مخطط سيرًا على الأقدام. اختر وضع المشي في Google Maps وشارك المسار من جديد.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'لا يذكر الرابط بوضوح أن المسار سيرًا على الأقدام. اختر المشي في Google Maps وشاركه من جديد.',
|
||||
'tours.walkImport.refusal.short_link_refused': 'يقود الرابط المختصر إلى مكان غير Google Maps، لذلك لم يُتابَع.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'تعذرت متابعة الرابط المختصر الآن. حاول مرة أخرى أو الصق الرابط الطويل.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'أمكن قراءة أقل من محطتين من هذا الرابط.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'في هذا المسار محطات أكثر مما تتسع له جولة المشي.',
|
||||
'tours.provenance.takenOverFrom': 'منقولة من',
|
||||
'tours.poi.title': 'على طول الجولة',
|
||||
'tours.poi.intro': 'أماكن للتوقف قريبة من المسار. لا يتغير شيء حتى تضيف واحدًا.',
|
||||
|
||||
@@ -94,7 +94,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Tur planlaşdır',
|
||||
'tours.planner.newTour': 'Yeni tur planlaşdır',
|
||||
'tours.planner.addTour': 'Tur əlavə et',
|
||||
'tours.planner.addTourHint': 'Yeni tur planla, GPX fayllarını idxal et və ya başqa səfərdən tur götür',
|
||||
'tours.planner.addTourHint':
|
||||
'Yeni tur planla, GPX fayllarını və ya Google Maps-dən gəzinti idxal et, ya da başqa səfərdən tur götür',
|
||||
'tours.planner.neutralTitle': 'Tur planlaşdır',
|
||||
'tours.planner.neutralBody': 'Yeni tur başladın və marşrut nöqtələrini qoymaq üçün xəritəyə klikləyin.',
|
||||
'tours.planner.newTitle': 'Yeni tur',
|
||||
@@ -308,6 +309,43 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Götür',
|
||||
'tours.library.taken': '“{name}” bu səfərə götürüldü.',
|
||||
'tours.library.failed': 'Tur götürülə bilmədi.',
|
||||
'tours.walkImport.menu': 'Google Maps-dən gəzinti idxal et',
|
||||
'tours.walkImport.title': 'Google Maps keçidindən gəzinti',
|
||||
'tours.walkImport.intro':
|
||||
'Google Maps-də piyada marşrutu paylaş və keçidini bura yapışdır. TREK dayanacaqları oxuyur və onların arasındakı piyada yolu özü planlaşdırır.',
|
||||
'tours.walkImport.urlLabel': 'Piyada marşrutunun keçidi',
|
||||
'tours.walkImport.urlHint': 'maps.app.goo.gl qısa keçidi və ya marşrutun google.com/maps ünvanı.',
|
||||
'tours.walkImport.read': 'Keçidi oxu',
|
||||
'tours.walkImport.back': 'Başqa keçid götür',
|
||||
'tours.walkImport.load': 'Gəzinti kimi yüklə',
|
||||
'tours.walkImport.stops': 'Keçiddəki dayanacaqlar',
|
||||
'tours.walkImport.source': 'Google Maps-dən',
|
||||
'tours.walkImport.openSource': 'Google Maps-də aç',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK dayanacaqlar arasındakı yolu yenidən planlaşdırır; Google Maps-də çəkilmiş xətt götürülmür.',
|
||||
'tours.walkImport.noPosition': 'Keçiddə mövqe yoxdur',
|
||||
'tours.walkImport.unresolved': '{count} dayanacağın keçiddə mövqeyi yoxdur və kənarda qalır.',
|
||||
'tours.walkImport.unresolved.one': '{count} dayanacağın keçiddə mövqeyi yoxdur və kənarda qalır.',
|
||||
'tours.walkImport.tooFewPositions': 'Gəzinti üçün mövqeyi olan ən azı iki dayanacaq lazımdır.',
|
||||
'tours.walkImport.replacesDraft': 'Gəzintini yükləmək planlayıcıdakı yadda saxlanmamış dəyişikliklərin yerini tutur.',
|
||||
'tours.walkImport.defaultName': 'Google Maps-dən gəzinti',
|
||||
'tours.walkImport.draftNotice':
|
||||
'Google Maps piyada marşrutundan. Dayanacaqlar arasındakı yolu TREK özü planlaşdırdı.',
|
||||
'tours.walkImport.offline': 'Keçidi oxumaq üçün bağlantı lazımdır.',
|
||||
'tours.walkImport.failed': 'Keçidi indi oxumaq mümkün olmadı. Bir azdan yenidən cəhd et.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Google Maps marşrut keçidindən istifadə et: maps.app.goo.gl qısa keçidi və ya google.com/maps/dir keçidi.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Bu Google Maps keçidi marşrut deyil. Gəzintini Google Maps-də planlaşdır və marşrutu paylaş.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Bu marşrut piyada planlaşdırılmayıb. Google Maps-i piyada rejiminə keçir və marşrutu yenidən paylaş.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'Keçid marşrutun piyada olduğunu aydın demir. Google Maps-də piyada seç və onu yenidən paylaş.',
|
||||
'tours.walkImport.refusal.short_link_refused': 'Qısa keçid Google Maps-dən başqa yerə aparır, ona görə də izlənmədi.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Qısa keçidi indi izləmək mümkün olmadı. Yenidən cəhd et və ya uzun keçidi yapışdır.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Bu keçiddən ikidən az dayanacaq oxuna bildi.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'Bu marşrutda bir gəzintinin götürə biləcəyindən çox dayanacaq var.',
|
||||
'tours.provenance.takenOverFrom': 'Götürüldüyü yer',
|
||||
'tours.poi.title': 'Tur boyunca',
|
||||
'tours.poi.intro': 'Marşruta yaxın dayanacaq yerləri. Birini əlavə edənə qədər heç nə dəyişmir.',
|
||||
|
||||
@@ -98,7 +98,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Planejar um passeio',
|
||||
'tours.planner.newTour': 'Planejar novo passeio',
|
||||
'tours.planner.addTour': 'Adicionar passeio',
|
||||
'tours.planner.addTourHint': 'Planeje um novo passeio, importe arquivos GPX ou traga um passeio de outra viagem',
|
||||
'tours.planner.addTourHint':
|
||||
'Planeje um novo passeio, importe arquivos GPX ou um passeio do Google Maps, ou traga um passeio de outra viagem',
|
||||
'tours.planner.neutralTitle': 'Planejar um passeio',
|
||||
'tours.planner.neutralBody': 'Comece um novo passeio e clique no mapa para marcar os pontos do percurso.',
|
||||
'tours.planner.newTitle': 'Novo passeio',
|
||||
@@ -313,6 +314,44 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Trazer',
|
||||
'tours.library.taken': '“{name}” foi trazido para esta viagem.',
|
||||
'tours.library.failed': 'Não foi possível trazer o passeio.',
|
||||
'tours.walkImport.menu': 'Importar passeio do Google Maps',
|
||||
'tours.walkImport.title': 'Passeio a partir de um link do Google Maps',
|
||||
'tours.walkImport.intro':
|
||||
'Compartilhe uma rota a pé no Google Maps e cole o link aqui. O TREK lê as paradas e traça por conta própria o caminho a pé entre elas.',
|
||||
'tours.walkImport.urlLabel': 'Link da rota a pé',
|
||||
'tours.walkImport.urlHint': 'Um link curto do maps.app.goo.gl ou o endereço da rota em google.com/maps.',
|
||||
'tours.walkImport.read': 'Ler o link',
|
||||
'tours.walkImport.back': 'Usar outro link',
|
||||
'tours.walkImport.load': 'Carregar como passeio',
|
||||
'tours.walkImport.stops': 'Paradas do link',
|
||||
'tours.walkImport.source': 'Do Google Maps',
|
||||
'tours.walkImport.openSource': 'Abrir no Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
'O TREK traça de novo o caminho entre as paradas; a linha desenhada no Google Maps não é aproveitada.',
|
||||
'tours.walkImport.noPosition': 'Sem posição no link',
|
||||
'tours.walkImport.unresolved': '{count} paradas não têm posição no link e ficam de fora.',
|
||||
'tours.walkImport.unresolved.one': '{count} parada não tem posição no link e fica de fora.',
|
||||
'tours.walkImport.tooFewPositions': 'Um passeio precisa de pelo menos duas paradas com posição.',
|
||||
'tours.walkImport.replacesDraft': 'Carregar o passeio substitui as alterações não salvas do planejador.',
|
||||
'tours.walkImport.defaultName': 'Passeio do Google Maps',
|
||||
'tours.walkImport.draftNotice':
|
||||
'De uma rota a pé do Google Maps. O TREK traçou por conta própria o caminho entre as paradas.',
|
||||
'tours.walkImport.offline': 'Ler um link precisa de conexão.',
|
||||
'tours.walkImport.failed': 'Não foi possível ler o link agora. Tente de novo daqui a pouco.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Use um link de rota do Google Maps: um link curto do maps.app.goo.gl ou um link do google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Esse link do Google Maps não é uma rota. Planeje o passeio no Google Maps e compartilhe a rota.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Essa rota não foi planejada a pé. Mude o Google Maps para a pé e compartilhe a rota de novo.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'O link não diz com clareza que a rota é a pé. Escolha a pé no Google Maps e compartilhe de novo.',
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
'O link curto leva a outro lugar que não o Google Maps, então não foi seguido.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Não foi possível seguir o link curto agora. Tente de novo ou cole o link longo.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Menos de duas paradas puderam ser lidas desse link.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'Essa rota tem mais paradas do que um passeio comporta.',
|
||||
'tours.provenance.takenOverFrom': 'Trazido de',
|
||||
'tours.poi.title': 'Ao longo do passeio',
|
||||
'tours.poi.intro': 'Lugares para uma parada, perto da rota. Nada muda até você adicionar um.',
|
||||
|
||||
@@ -98,7 +98,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Planifica una excursió',
|
||||
'tours.planner.newTour': 'Planifica una nova excursió',
|
||||
'tours.planner.addTour': 'Afegeix una ruta',
|
||||
'tours.planner.addTourHint': "Planifica una ruta nova, importa fitxers GPX o porta una ruta d'un altre viatge",
|
||||
'tours.planner.addTourHint':
|
||||
"Planifica una ruta nova, importa fitxers GPX o un passeig de Google Maps, o porta una ruta d'un altre viatge",
|
||||
'tours.planner.neutralTitle': 'Planifica una excursió',
|
||||
'tours.planner.neutralBody': 'Comença una ruta nova i fes clic al mapa per marcar-ne els punts.',
|
||||
'tours.planner.newTitle': 'Nova excursió',
|
||||
@@ -314,6 +315,44 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Agafar',
|
||||
'tours.library.taken': "S'ha agafat «{name}» en aquest viatge.",
|
||||
'tours.library.failed': "No s'ha pogut agafar la ruta.",
|
||||
'tours.walkImport.menu': 'Importa un passeig de Google Maps',
|
||||
'tours.walkImport.title': "Passeig des d'un enllaç de Google Maps",
|
||||
'tours.walkImport.intro':
|
||||
"Comparteix una ruta a peu a Google Maps i enganxa'n l'enllaç aquí. TREK llegeix les parades i traça pel seu compte el camí a peu entre elles.",
|
||||
'tours.walkImport.urlLabel': 'Enllaç a la ruta a peu',
|
||||
'tours.walkImport.urlHint': "Un enllaç curt de maps.app.goo.gl o l'adreça de la ruta a google.com/maps.",
|
||||
'tours.walkImport.read': "Llegeix l'enllaç",
|
||||
'tours.walkImport.back': 'Fes servir un altre enllaç',
|
||||
'tours.walkImport.load': 'Carrega com a passeig',
|
||||
'tours.walkImport.stops': "Parades de l'enllaç",
|
||||
'tours.walkImport.source': 'De Google Maps',
|
||||
'tours.walkImport.openSource': 'Obre a Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
"TREK torna a traçar el camí entre les parades; la línia dibuixada a Google Maps no s'aprofita.",
|
||||
'tours.walkImport.noPosition': "Sense posició a l'enllaç",
|
||||
'tours.walkImport.unresolved': "{count} parades no tenen posició a l'enllaç i es deixen fora.",
|
||||
'tours.walkImport.unresolved.one': "{count} parada no té posició a l'enllaç i es deixa fora.",
|
||||
'tours.walkImport.tooFewPositions': 'Un passeig necessita com a mínim dues parades amb posició.',
|
||||
'tours.walkImport.replacesDraft': 'Carregar el passeig substitueix els canvis no desats del planificador.',
|
||||
'tours.walkImport.defaultName': 'Passeig de Google Maps',
|
||||
'tours.walkImport.draftNotice':
|
||||
"D'una ruta a peu de Google Maps. TREK ha traçat pel seu compte el camí entre les parades.",
|
||||
'tours.walkImport.offline': 'Per llegir un enllaç cal connexió.',
|
||||
'tours.walkImport.failed': "Ara no s'ha pogut llegir l'enllaç. Torna-ho a provar d'aquí a una estona.",
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Fes servir un enllaç de ruta de Google Maps: un enllaç curt de maps.app.goo.gl o un enllaç de google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Aquest enllaç de Google Maps no és una ruta. Planifica el passeig a Google Maps i comparteix la ruta.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Aquesta ruta no està planificada a peu. Canvia Google Maps a peu i torna a compartir la ruta.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
"L'enllaç no diu clarament que la ruta sigui a peu. Tria a peu a Google Maps i torna-la a compartir.",
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
"L'enllaç curt porta a un lloc que no és Google Maps, així que no s'ha seguit.",
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
"Ara no s'ha pogut seguir l'enllaç curt. Torna-ho a provar o enganxa l'enllaç llarg.",
|
||||
'tours.walkImport.refusal.too_few_stops': "D'aquest enllaç s'han pogut llegir menys de dues parades.",
|
||||
'tours.walkImport.refusal.too_many_stops': 'Aquesta ruta té més parades de les que admet un passeig.',
|
||||
'tours.provenance.takenOverFrom': 'Agafada de',
|
||||
'tours.poi.title': 'Al llarg de la ruta',
|
||||
'tours.poi.intro': "Llocs per fer una parada, a prop de la ruta. No canvia res fins que n'afegeixis un.",
|
||||
|
||||
@@ -98,7 +98,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Naplánovat výlet',
|
||||
'tours.planner.newTour': 'Naplánovat nový výlet',
|
||||
'tours.planner.addTour': 'Přidat túru',
|
||||
'tours.planner.addTourHint': 'Naplánuj novou túru, importuj soubory GPX nebo převezmi túru z jiné cesty',
|
||||
'tours.planner.addTourHint':
|
||||
'Naplánuj novou túru, importuj soubory GPX nebo procházku z Google Maps, nebo převezmi túru z jiné cesty',
|
||||
'tours.planner.neutralTitle': 'Naplánovat výlet',
|
||||
'tours.planner.neutralBody': 'Začněte novou túru a kliknutím do mapy umístěte její body.',
|
||||
'tours.planner.newTitle': 'Nový výlet',
|
||||
@@ -308,6 +309,43 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Převzít',
|
||||
'tours.library.taken': '„{name}“ byla převzata do této cesty.',
|
||||
'tours.library.failed': 'Túru se nepodařilo převzít.',
|
||||
'tours.walkImport.menu': 'Importovat procházku z Google Maps',
|
||||
'tours.walkImport.title': 'Procházka z odkazu Google Maps',
|
||||
'tours.walkImport.intro':
|
||||
'Sdílej pěší trasu v Google Maps a vlož sem její odkaz. TREK přečte zastávky a pěší cestu mezi nimi naplánuje sám.',
|
||||
'tours.walkImport.urlLabel': 'Odkaz na pěší trasu',
|
||||
'tours.walkImport.urlHint': 'Krátký odkaz maps.app.goo.gl nebo adresa trasy na google.com/maps.',
|
||||
'tours.walkImport.read': 'Přečíst odkaz',
|
||||
'tours.walkImport.back': 'Použít jiný odkaz',
|
||||
'tours.walkImport.load': 'Načíst jako procházku',
|
||||
'tours.walkImport.stops': 'Zastávky v odkazu',
|
||||
'tours.walkImport.source': 'Z Google Maps',
|
||||
'tours.walkImport.openSource': 'Otevřít v Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK naplánuje cestu mezi zastávkami znovu; čára nakreslená v Google Maps se nepřebírá.',
|
||||
'tours.walkImport.noPosition': 'V odkazu chybí poloha',
|
||||
'tours.walkImport.unresolved': '{count} zastávek nemá v odkazu polohu, proto je vynecháme.',
|
||||
'tours.walkImport.unresolved.one': '{count} zastávka nemá v odkazu polohu a vynechává se.',
|
||||
'tours.walkImport.unresolved.few': '{count} zastávky nemají v odkazu polohu a vynechávají se.',
|
||||
'tours.walkImport.tooFewPositions': 'Procházka potřebuje aspoň dvě zastávky s polohou.',
|
||||
'tours.walkImport.replacesDraft': 'Načtení procházky nahradí neuložené změny v plánovači.',
|
||||
'tours.walkImport.defaultName': 'Procházka z Google Maps',
|
||||
'tours.walkImport.draftNotice': 'Z pěší trasy v Google Maps. Cestu mezi zastávkami naplánoval TREK sám.',
|
||||
'tours.walkImport.offline': 'Ke čtení odkazu je potřeba připojení.',
|
||||
'tours.walkImport.failed': 'Odkaz se teď nepodařilo přečíst. Zkus to za chvíli znovu.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Použij odkaz na trasu v Google Maps: krátký odkaz maps.app.goo.gl nebo odkaz google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Tento odkaz Google Maps není trasa. Naplánuj procházku v Google Maps a sdílej trasu.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Tato trasa není naplánovaná pěšky. Přepni Google Maps na chůzi a sdílej trasu znovu.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'Z odkazu není jasné, že je trasa pěší. Zvol v Google Maps chůzi a sdílej ji znovu.',
|
||||
'tours.walkImport.refusal.short_link_refused': 'Krátký odkaz vede jinam než do Google Maps, proto se nesledoval.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Krátký odkaz se teď nepodařilo sledovat. Zkus to znovu, nebo vlož dlouhý odkaz.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Z tohoto odkazu se podařilo přečíst méně než dvě zastávky.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'Tato trasa má víc zastávek, než procházka pojme.',
|
||||
'tours.provenance.takenOverFrom': 'Převzato z',
|
||||
'tours.poi.title': 'Podél túry',
|
||||
'tours.poi.intro': 'Místa na zastávku blízko trasy. Nic se nezmění, dokud nějaké nepřidáš.',
|
||||
|
||||
@@ -99,7 +99,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Neue Tour planen',
|
||||
'tours.planner.addTour': 'Tour hinzufügen',
|
||||
'tours.planner.addTourHint':
|
||||
'Neue Tour planen, GPX-Dateien importieren oder eine Tour aus einer anderen Reise übernehmen',
|
||||
'Neue Tour planen, GPX-Dateien oder einen Walk aus Google Maps importieren oder eine Tour aus einer anderen Reise übernehmen',
|
||||
'tours.planner.neutralTitle': 'Tour planen',
|
||||
'tours.planner.neutralBody': 'Starte eine neue Tour und klicke auf die Karte, um die Wegpunkte zu setzen.',
|
||||
'tours.planner.newTitle': 'Neue Tour',
|
||||
@@ -317,6 +317,44 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Übernehmen',
|
||||
'tours.library.taken': '„{name}“ wurde in diese Reise übernommen.',
|
||||
'tours.library.failed': 'Die Tour konnte nicht übernommen werden.',
|
||||
'tours.walkImport.menu': 'Walk aus Google Maps importieren',
|
||||
'tours.walkImport.title': 'Walk aus einem Google-Maps-Link',
|
||||
'tours.walkImport.intro':
|
||||
'Teile eine Fußroute in Google Maps und füge ihren Link hier ein. TREK liest die Stationen und plant den Fußweg zwischen ihnen selbst.',
|
||||
'tours.walkImport.urlLabel': 'Link zur Fußroute',
|
||||
'tours.walkImport.urlHint': 'Ein Kurzlink von maps.app.goo.gl oder die Adresse der Route auf google.com/maps.',
|
||||
'tours.walkImport.read': 'Link lesen',
|
||||
'tours.walkImport.back': 'Anderen Link nehmen',
|
||||
'tours.walkImport.load': 'Als Walk laden',
|
||||
'tours.walkImport.stops': 'Stationen im Link',
|
||||
'tours.walkImport.source': 'Aus Google Maps',
|
||||
'tours.walkImport.openSource': 'In Google Maps öffnen',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK plant den Weg zwischen den Stationen neu; die in Google Maps gezeichnete Linie wird nicht übernommen.',
|
||||
'tours.walkImport.noPosition': 'Keine Position im Link',
|
||||
'tours.walkImport.unresolved': '{count} Stationen haben im Link keine Position und werden weggelassen.',
|
||||
'tours.walkImport.unresolved.one': '{count} Station hat im Link keine Position und wird weggelassen.',
|
||||
'tours.walkImport.tooFewPositions': 'Ein Walk braucht mindestens zwei Stationen mit Position.',
|
||||
'tours.walkImport.replacesDraft': 'Das Laden des Walks ersetzt die nicht gespeicherten Änderungen im Planer.',
|
||||
'tours.walkImport.defaultName': 'Walk aus Google Maps',
|
||||
'tours.walkImport.draftNotice':
|
||||
'Aus einer Fußroute von Google Maps. Den Weg zwischen den Stationen hat TREK selbst geplant.',
|
||||
'tours.walkImport.offline': 'Zum Lesen eines Links braucht es eine Verbindung.',
|
||||
'tours.walkImport.failed': 'Der Link ließ sich gerade nicht lesen. Versuch es gleich noch einmal.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Nimm einen Routenlink von Google Maps: einen Kurzlink von maps.app.goo.gl oder einen Link auf google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Dieser Google-Maps-Link ist keine Route. Plane den Walk in Google Maps und teile die Route.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Diese Route ist nicht zu Fuß geplant. Stell Google Maps auf zu Fuß um und teile die Route erneut.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'Der Link sagt nicht eindeutig, dass die Route zu Fuß ist. Wähle in Google Maps zu Fuß und teile sie erneut.',
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
'Der Kurzlink führt nicht zu Google Maps und wurde deshalb nicht aufgerufen.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Der Kurzlink ließ sich gerade nicht auflösen. Versuch es noch einmal oder füge den langen Link ein.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Aus diesem Link ließen sich keine zwei Stationen lesen.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'Diese Route hat mehr Stationen, als ein Walk aufnehmen kann.',
|
||||
'tours.provenance.takenOverFrom': 'Übernommen aus',
|
||||
'tours.poi.title': 'Entlang der Tour',
|
||||
'tours.poi.intro': 'Orte für einen Stopp, nah an der Route. Es ändert sich nichts, bis du einen hinzufügst.',
|
||||
|
||||
@@ -94,7 +94,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Plan a tour',
|
||||
'tours.planner.newTour': 'Plan new tour',
|
||||
'tours.planner.addTour': 'Add tour',
|
||||
'tours.planner.addTourHint': 'Plan a new tour, import GPX files or take a tour over from another trip',
|
||||
'tours.planner.addTourHint':
|
||||
'Plan a new tour, import GPX files or a Google Maps walk, or take a tour over from another trip',
|
||||
'tours.planner.neutralTitle': 'Plan a tour',
|
||||
'tours.planner.neutralBody': 'Start a new tour and click the map to place its waypoints.',
|
||||
'tours.planner.newTitle': 'New tour',
|
||||
@@ -307,6 +308,43 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Take over',
|
||||
'tours.library.taken': '“{name}” was taken over into this trip.',
|
||||
'tours.library.failed': 'The tour could not be taken over.',
|
||||
'tours.walkImport.menu': 'Import walk from Google Maps',
|
||||
'tours.walkImport.title': 'Walk from a Google Maps link',
|
||||
'tours.walkImport.intro':
|
||||
'Share a walking route in Google Maps and paste its link here. TREK reads the stops and plans the way on foot between them itself.',
|
||||
'tours.walkImport.urlLabel': 'Link to the walking route',
|
||||
'tours.walkImport.urlHint': 'A maps.app.goo.gl short link, or the address of the route on google.com/maps.',
|
||||
'tours.walkImport.read': 'Read the link',
|
||||
'tours.walkImport.back': 'Use another link',
|
||||
'tours.walkImport.load': 'Load as walk',
|
||||
'tours.walkImport.stops': 'Stops in the link',
|
||||
'tours.walkImport.source': 'From Google Maps',
|
||||
'tours.walkImport.openSource': 'Open in Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK plans the way between the stops again; the line drawn in Google Maps is not taken over.',
|
||||
'tours.walkImport.noPosition': 'No position in the link',
|
||||
'tours.walkImport.unresolved': '{count} stops have no position in the link and are left out.',
|
||||
'tours.walkImport.unresolved.one': '{count} stop has no position in the link and is left out.',
|
||||
'tours.walkImport.tooFewPositions': 'A walk needs at least two stops with a position.',
|
||||
'tours.walkImport.replacesDraft': 'Loading the walk replaces the unsaved changes in the planner.',
|
||||
'tours.walkImport.defaultName': 'Walk from Google Maps',
|
||||
'tours.walkImport.draftNotice': 'From a Google Maps walking route. TREK planned the way between the stops itself.',
|
||||
'tours.walkImport.offline': 'Reading a link needs a connection.',
|
||||
'tours.walkImport.failed': 'The link could not be read just now. Try again in a moment.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Use a Google Maps route link: a maps.app.goo.gl short link or a google.com/maps/dir link.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'That Google Maps link is not a route. Plan the walk in Google Maps and share the route.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'That route is not planned on foot. Switch Google Maps to walking and share the route again.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'The link does not say clearly that the route is on foot. Choose walking in Google Maps and share it again.',
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
'The short link leads somewhere other than Google Maps, so it was not followed.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'The short link could not be followed just now. Try again, or paste the long link.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Fewer than two stops could be read from that link.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'That route has more stops than a walk can take.',
|
||||
'tours.provenance.takenOverFrom': 'Taken over from',
|
||||
'tours.poi.title': 'Along the tour',
|
||||
'tours.poi.intro': 'Places for a stop, close to the route. Nothing changes until you add one.',
|
||||
|
||||
@@ -99,7 +99,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Planificar una excursión',
|
||||
'tours.planner.newTour': 'Planificar nueva excursión',
|
||||
'tours.planner.addTour': 'Añadir ruta',
|
||||
'tours.planner.addTourHint': 'Planifica una ruta nueva, importa archivos GPX o trae una ruta de otro viaje',
|
||||
'tours.planner.addTourHint':
|
||||
'Planifica una ruta nueva, importa archivos GPX o un paseo de Google Maps, o trae una ruta de otro viaje',
|
||||
'tours.planner.neutralTitle': 'Planificar una excursión',
|
||||
'tours.planner.neutralBody': 'Empieza una ruta nueva y haz clic en el mapa para colocar sus puntos.',
|
||||
'tours.planner.newTitle': 'Nueva excursión',
|
||||
@@ -313,6 +314,44 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Traer',
|
||||
'tours.library.taken': '«{name}» se trajo a este viaje.',
|
||||
'tours.library.failed': 'No se pudo traer la ruta.',
|
||||
'tours.walkImport.menu': 'Importar paseo de Google Maps',
|
||||
'tours.walkImport.title': 'Paseo desde un enlace de Google Maps',
|
||||
'tours.walkImport.intro':
|
||||
'Comparte una ruta a pie en Google Maps y pega aquí su enlace. TREK lee las paradas y traza por su cuenta el camino a pie entre ellas.',
|
||||
'tours.walkImport.urlLabel': 'Enlace a la ruta a pie',
|
||||
'tours.walkImport.urlHint': 'Un enlace corto de maps.app.goo.gl o la dirección de la ruta en google.com/maps.',
|
||||
'tours.walkImport.read': 'Leer el enlace',
|
||||
'tours.walkImport.back': 'Usar otro enlace',
|
||||
'tours.walkImport.load': 'Cargar como paseo',
|
||||
'tours.walkImport.stops': 'Paradas del enlace',
|
||||
'tours.walkImport.source': 'Desde Google Maps',
|
||||
'tours.walkImport.openSource': 'Abrir en Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK vuelve a trazar el camino entre las paradas; la línea dibujada en Google Maps no se toma.',
|
||||
'tours.walkImport.noPosition': 'Sin posición en el enlace',
|
||||
'tours.walkImport.unresolved': '{count} paradas no tienen posición en el enlace y se dejan fuera.',
|
||||
'tours.walkImport.unresolved.one': '{count} parada no tiene posición en el enlace y se deja fuera.',
|
||||
'tours.walkImport.tooFewPositions': 'Un paseo necesita al menos dos paradas con posición.',
|
||||
'tours.walkImport.replacesDraft': 'Cargar el paseo sustituye los cambios sin guardar del planificador.',
|
||||
'tours.walkImport.defaultName': 'Paseo de Google Maps',
|
||||
'tours.walkImport.draftNotice':
|
||||
'De una ruta a pie de Google Maps. TREK trazó por su cuenta el camino entre las paradas.',
|
||||
'tours.walkImport.offline': 'Leer un enlace necesita conexión.',
|
||||
'tours.walkImport.failed': 'No se pudo leer el enlace ahora. Vuelve a intentarlo en un momento.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Usa un enlace de ruta de Google Maps: un enlace corto de maps.app.goo.gl o un enlace de google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Ese enlace de Google Maps no es una ruta. Planifica el paseo en Google Maps y comparte la ruta.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Esa ruta no está planificada a pie. Cambia Google Maps a pie y vuelve a compartir la ruta.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'El enlace no dice con claridad que la ruta sea a pie. Elige a pie en Google Maps y vuelve a compartirla.',
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
'El enlace corto lleva a otro sitio que no es Google Maps, así que no se siguió.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'No se pudo seguir el enlace corto ahora. Vuelve a intentarlo o pega el enlace largo.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'De ese enlace se pudieron leer menos de dos paradas.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'Esa ruta tiene más paradas de las que admite un paseo.',
|
||||
'tours.provenance.takenOverFrom': 'Traída de',
|
||||
'tours.poi.title': 'A lo largo de la ruta',
|
||||
'tours.poi.intro': 'Lugares para una parada, cerca de la ruta. Nada cambia hasta que añadas uno.',
|
||||
|
||||
@@ -95,7 +95,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Planeeri retk',
|
||||
'tours.planner.newTour': 'Planeeri uus retk',
|
||||
'tours.planner.addTour': 'Lisa matk',
|
||||
'tours.planner.addTourHint': 'Planeeri uus matk, impordi GPX-faile või võta matk üle teiselt reisilt',
|
||||
'tours.planner.addTourHint':
|
||||
'Planeeri uus matk, impordi GPX-faile või Google Mapsi jalutuskäik või võta matk üle teiselt reisilt',
|
||||
'tours.planner.neutralTitle': 'Planeeri retk',
|
||||
'tours.planner.neutralBody': 'Alusta uut matka ja klõpsa kaardil, et selle punktid paigutada.',
|
||||
'tours.planner.newTitle': 'Uus retk',
|
||||
@@ -310,6 +311,42 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Võta üle',
|
||||
'tours.library.taken': '„{name}“ võeti sellele reisile üle.',
|
||||
'tours.library.failed': 'Matka ei õnnestunud üle võtta.',
|
||||
'tours.walkImport.menu': 'Impordi jalutuskäik Google Mapsist',
|
||||
'tours.walkImport.title': 'Jalutuskäik Google Mapsi lingist',
|
||||
'tours.walkImport.intro':
|
||||
'Jaga Google Mapsis jalgsimarsruuti ja kleebi selle link siia. TREK loeb peatused ja planeerib jalgsitee nende vahel ise.',
|
||||
'tours.walkImport.urlLabel': 'Link jalgsimarsruudile',
|
||||
'tours.walkImport.urlHint': 'Lühilink maps.app.goo.gl või marsruudi aadress lehel google.com/maps.',
|
||||
'tours.walkImport.read': 'Loe linki',
|
||||
'tours.walkImport.back': 'Kasuta teist linki',
|
||||
'tours.walkImport.load': 'Laadi jalutuskäiguna',
|
||||
'tours.walkImport.stops': 'Lingi peatused',
|
||||
'tours.walkImport.source': 'Google Mapsist',
|
||||
'tours.walkImport.openSource': 'Ava Google Mapsis',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK planeerib tee peatuste vahel uuesti; Google Mapsis joonistatud joont üle ei võeta.',
|
||||
'tours.walkImport.noPosition': 'Lingis pole asukohta',
|
||||
'tours.walkImport.unresolved': '{count} peatusel pole lingis asukohta ja need jäetakse välja.',
|
||||
'tours.walkImport.unresolved.one': '{count} peatusel pole lingis asukohta ja see jäetakse välja.',
|
||||
'tours.walkImport.tooFewPositions': 'Jalutuskäiguks on vaja vähemalt kahte asukohaga peatust.',
|
||||
'tours.walkImport.replacesDraft': 'Jalutuskäigu laadimine asendab planeerija salvestamata muudatused.',
|
||||
'tours.walkImport.defaultName': 'Jalutuskäik Google Mapsist',
|
||||
'tours.walkImport.draftNotice': 'Google Mapsi jalgsimarsruudist. Tee peatuste vahel planeeris TREK ise.',
|
||||
'tours.walkImport.offline': 'Lingi lugemiseks on vaja ühendust.',
|
||||
'tours.walkImport.failed': 'Linki ei õnnestunud praegu lugeda. Proovi hetke pärast uuesti.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Kasuta Google Mapsi marsruudi linki: lühilinki maps.app.goo.gl või linki google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'See Google Mapsi link pole marsruut. Planeeri jalutuskäik Google Mapsis ja jaga marsruuti.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'See marsruut pole planeeritud jalgsi. Lülita Google Maps jalgsi peale ja jaga marsruuti uuesti.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'Link ei ütle selgelt, et marsruut on jalgsi. Vali Google Mapsis jalgsi ja jaga seda uuesti.',
|
||||
'tours.walkImport.refusal.short_link_refused': 'Lühilink viib mujale kui Google Mapsi, seepärast seda ei järgitud.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Lühilinki ei õnnestunud praegu järgida. Proovi uuesti või kleebi pikk link.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Sellest lingist õnnestus lugeda vähem kui kaks peatust.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'Sellel marsruudil on rohkem peatusi, kui jalutuskäiku mahub.',
|
||||
'tours.provenance.takenOverFrom': 'Üle võetud',
|
||||
'tours.poi.title': 'Matka ääres',
|
||||
'tours.poi.intro': 'Peatuskohad marsruudi lähedal. Midagi ei muutu enne, kui mõne lisad.',
|
||||
|
||||
@@ -101,7 +101,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Planifier une nouvelle sortie',
|
||||
'tours.planner.addTour': 'Ajouter une sortie',
|
||||
'tours.planner.addTourHint':
|
||||
"Planifier une nouvelle sortie, importer des fichiers GPX ou reprendre une sortie d'un autre voyage",
|
||||
"Planifier une nouvelle sortie, importer des fichiers GPX ou une balade Google Maps, ou reprendre une sortie d'un autre voyage",
|
||||
'tours.planner.neutralTitle': 'Planifier une sortie',
|
||||
'tours.planner.neutralBody': 'Commencez une nouvelle randonnée et cliquez sur la carte pour placer ses points.',
|
||||
'tours.planner.newTitle': 'Nouvelle sortie',
|
||||
@@ -320,6 +320,44 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Reprendre',
|
||||
'tours.library.taken': '« {name} » a été repris dans ce voyage.',
|
||||
'tours.library.failed': "L'itinéraire n'a pas pu être repris.",
|
||||
'tours.walkImport.menu': 'Importer une balade depuis Google Maps',
|
||||
'tours.walkImport.title': 'Balade depuis un lien Google Maps',
|
||||
'tours.walkImport.intro':
|
||||
'Partage un itinéraire à pied dans Google Maps et colle son lien ici. TREK lit les étapes et trace lui-même le chemin à pied entre elles.',
|
||||
'tours.walkImport.urlLabel': "Lien vers l'itinéraire à pied",
|
||||
'tours.walkImport.urlHint': "Un lien court maps.app.goo.gl, ou l'adresse de l'itinéraire sur google.com/maps.",
|
||||
'tours.walkImport.read': 'Lire le lien',
|
||||
'tours.walkImport.back': 'Prendre un autre lien',
|
||||
'tours.walkImport.load': 'Charger comme balade',
|
||||
'tours.walkImport.stops': 'Étapes du lien',
|
||||
'tours.walkImport.source': 'Depuis Google Maps',
|
||||
'tours.walkImport.openSource': 'Ouvrir dans Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
"TREK retrace le chemin entre les étapes ; la ligne dessinée dans Google Maps n'est pas reprise.",
|
||||
'tours.walkImport.noPosition': 'Pas de position dans le lien',
|
||||
'tours.walkImport.unresolved': "{count} étapes n'ont pas de position dans le lien et sont laissées de côté.",
|
||||
'tours.walkImport.unresolved.one': "{count} étape n'a pas de position dans le lien et est laissée de côté.",
|
||||
'tours.walkImport.tooFewPositions': 'Une balade demande au moins deux étapes avec une position.',
|
||||
'tours.walkImport.replacesDraft': 'Charger la balade remplace les modifications non enregistrées du planificateur.',
|
||||
'tours.walkImport.defaultName': 'Balade depuis Google Maps',
|
||||
'tours.walkImport.draftNotice':
|
||||
"Tirée d'un itinéraire à pied de Google Maps. TREK a tracé lui-même le chemin entre les étapes.",
|
||||
'tours.walkImport.offline': 'Lire un lien nécessite une connexion.',
|
||||
'tours.walkImport.failed': "Le lien n'a pas pu être lu pour l'instant. Réessaie dans un moment.",
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
"Utilise un lien d'itinéraire Google Maps : un lien court maps.app.goo.gl ou un lien google.com/maps/dir.",
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
"Ce lien Google Maps n'est pas un itinéraire. Prépare la balade dans Google Maps et partage l'itinéraire.",
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
"Cet itinéraire n'est pas prévu à pied. Passe Google Maps en mode à pied et partage de nouveau l'itinéraire.",
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
"Le lien ne dit pas clairement que l'itinéraire se fait à pied. Choisis à pied dans Google Maps et partage-le de nouveau.",
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
"Le lien court mène ailleurs que vers Google Maps, il n'a donc pas été suivi.",
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
"Le lien court n'a pas pu être suivi pour l'instant. Réessaie, ou colle le lien long.",
|
||||
'tours.walkImport.refusal.too_few_stops': 'Moins de deux étapes ont pu être lues dans ce lien.',
|
||||
'tours.walkImport.refusal.too_many_stops': "Cet itinéraire compte plus d'étapes qu'une balade ne peut en prendre.",
|
||||
'tours.provenance.takenOverFrom': 'Repris de',
|
||||
'tours.poi.title': "Le long de l'itinéraire",
|
||||
'tours.poi.intro': "Des lieux où s'arrêter, près de l'itinéraire. Rien ne change tant que tu n'en ajoutes pas un.",
|
||||
|
||||
@@ -99,7 +99,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Σχεδιασμός εκδρομής',
|
||||
'tours.planner.newTour': 'Σχεδιασμός νέας εκδρομής',
|
||||
'tours.planner.addTour': 'Προσθήκη διαδρομής',
|
||||
'tours.planner.addTourHint': 'Σχεδίασε νέα διαδρομή, εισήγαγε αρχεία GPX ή πάρε μια διαδρομή από άλλο ταξίδι',
|
||||
'tours.planner.addTourHint':
|
||||
'Σχεδίασε νέα διαδρομή, εισήγαγε αρχεία GPX ή μια βόλτα από το Google Maps, ή πάρε μια διαδρομή από άλλο ταξίδι',
|
||||
'tours.planner.neutralTitle': 'Σχεδιασμός εκδρομής',
|
||||
'tours.planner.neutralBody': 'Ξεκινήστε μια νέα διαδρομή και κάντε κλικ στον χάρτη για να ορίσετε τα σημεία της.',
|
||||
'tours.planner.newTitle': 'Νέα εκδρομή',
|
||||
@@ -320,6 +321,45 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Μεταφορά',
|
||||
'tours.library.taken': 'Το «{name}» μεταφέρθηκε σε αυτό το ταξίδι.',
|
||||
'tours.library.failed': 'Δεν ήταν δυνατή η μεταφορά της διαδρομής.',
|
||||
'tours.walkImport.menu': 'Εισαγωγή βόλτας από το Google Maps',
|
||||
'tours.walkImport.title': 'Βόλτα από σύνδεσμο του Google Maps',
|
||||
'tours.walkImport.intro':
|
||||
'Μοιράσου μια διαδρομή με τα πόδια στο Google Maps και επικόλλησε εδώ τον σύνδεσμό της. Το TREK διαβάζει τις στάσεις και σχεδιάζει μόνο του τη διαδρομή με τα πόδια ανάμεσά τους.',
|
||||
'tours.walkImport.urlLabel': 'Σύνδεσμος της διαδρομής με τα πόδια',
|
||||
'tours.walkImport.urlHint':
|
||||
'Ένας σύντομος σύνδεσμος maps.app.goo.gl ή η διεύθυνση της διαδρομής στο google.com/maps.',
|
||||
'tours.walkImport.read': 'Ανάγνωση συνδέσμου',
|
||||
'tours.walkImport.back': 'Άλλος σύνδεσμος',
|
||||
'tours.walkImport.load': 'Φόρτωση ως βόλτα',
|
||||
'tours.walkImport.stops': 'Στάσεις στον σύνδεσμο',
|
||||
'tours.walkImport.source': 'Από το Google Maps',
|
||||
'tours.walkImport.openSource': 'Άνοιγμα στο Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
'Το TREK σχεδιάζει ξανά τη διαδρομή ανάμεσα στις στάσεις. Η γραμμή που σχεδιάστηκε στο Google Maps δεν μεταφέρεται.',
|
||||
'tours.walkImport.noPosition': 'Χωρίς θέση στον σύνδεσμο',
|
||||
'tours.walkImport.unresolved': '{count} στάσεις δεν έχουν θέση στον σύνδεσμο και παραλείπονται.',
|
||||
'tours.walkImport.unresolved.one': '{count} στάση δεν έχει θέση στον σύνδεσμο και παραλείπεται.',
|
||||
'tours.walkImport.tooFewPositions': 'Μια βόλτα χρειάζεται τουλάχιστον δύο στάσεις με θέση.',
|
||||
'tours.walkImport.replacesDraft': 'Η φόρτωση της βόλτας αντικαθιστά τις μη αποθηκευμένες αλλαγές του σχεδιαστή.',
|
||||
'tours.walkImport.defaultName': 'Βόλτα από το Google Maps',
|
||||
'tours.walkImport.draftNotice':
|
||||
'Από διαδρομή με τα πόδια του Google Maps. Το TREK σχεδίασε μόνο του τη διαδρομή ανάμεσα στις στάσεις.',
|
||||
'tours.walkImport.offline': 'Η ανάγνωση συνδέσμου χρειάζεται σύνδεση.',
|
||||
'tours.walkImport.failed': 'Ο σύνδεσμος δεν διαβάστηκε αυτή τη στιγμή. Δοκίμασε ξανά σε λίγο.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Χρησιμοποίησε σύνδεσμο διαδρομής του Google Maps: έναν σύντομο σύνδεσμο maps.app.goo.gl ή έναν σύνδεσμο google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Αυτός ο σύνδεσμος του Google Maps δεν είναι διαδρομή. Σχεδίασε τη βόλτα στο Google Maps και μοιράσου τη διαδρομή.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Αυτή η διαδρομή δεν είναι σχεδιασμένη με τα πόδια. Γύρισε το Google Maps σε «Με τα πόδια» και μοιράσου ξανά τη διαδρομή.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'Ο σύνδεσμος δεν λέει καθαρά ότι η διαδρομή είναι με τα πόδια. Διάλεξε «Με τα πόδια» στο Google Maps και μοιράσου την ξανά.',
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
'Ο σύντομος σύνδεσμος οδηγεί κάπου αλλού και όχι στο Google Maps, γι’ αυτό δεν ακολουθήθηκε.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Ο σύντομος σύνδεσμος δεν ακολουθήθηκε αυτή τη στιγμή. Δοκίμασε ξανά ή επικόλλησε τον μεγάλο σύνδεσμο.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Από αυτόν τον σύνδεσμο διαβάστηκαν λιγότερες από δύο στάσεις.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'Αυτή η διαδρομή έχει περισσότερες στάσεις απ’ όσες χωράει μια βόλτα.',
|
||||
'tours.provenance.takenOverFrom': 'Μεταφέρθηκε από',
|
||||
'tours.poi.title': 'Κατά μήκος της διαδρομής',
|
||||
'tours.poi.intro': 'Μέρη για στάση κοντά στη διαδρομή. Τίποτα δεν αλλάζει μέχρι να προσθέσεις κάποιο.',
|
||||
|
||||
@@ -96,7 +96,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Túra tervezése',
|
||||
'tours.planner.newTour': 'Új túra tervezése',
|
||||
'tours.planner.addTour': 'Túra hozzáadása',
|
||||
'tours.planner.addTourHint': 'Új túra tervezése, GPX-fájlok importálása vagy túra átvétele egy másik utazásból',
|
||||
'tours.planner.addTourHint':
|
||||
'Új túra tervezése, GPX-fájlok vagy egy Google Maps-séta importálása, vagy túra átvétele egy másik utazásból',
|
||||
'tours.planner.neutralTitle': 'Túra tervezése',
|
||||
'tours.planner.neutralBody': 'Indíts új túrát, és kattints a térképre a pontjai elhelyezéséhez.',
|
||||
'tours.planner.newTitle': 'Új túra',
|
||||
@@ -315,6 +316,43 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Átvétel',
|
||||
'tours.library.taken': '„{name}” átkerült ebbe az útba.',
|
||||
'tours.library.failed': 'A túrát nem sikerült átvenni.',
|
||||
'tours.walkImport.menu': 'Séta importálása a Google Mapsből',
|
||||
'tours.walkImport.title': 'Séta egy Google Maps-linkből',
|
||||
'tours.walkImport.intro':
|
||||
'Oszd meg a gyalogos útvonalat a Google Mapsben, és illeszd be ide a linkjét. A TREK beolvassa a megállókat, és maga tervezi meg köztük a gyalogutat.',
|
||||
'tours.walkImport.urlLabel': 'Link a gyalogos útvonalhoz',
|
||||
'tours.walkImport.urlHint': 'Egy maps.app.goo.gl rövid link vagy az útvonal címe a google.com/maps oldalon.',
|
||||
'tours.walkImport.read': 'Link beolvasása',
|
||||
'tours.walkImport.back': 'Másik link',
|
||||
'tours.walkImport.load': 'Betöltés sétaként',
|
||||
'tours.walkImport.stops': 'Megállók a linkben',
|
||||
'tours.walkImport.source': 'A Google Mapsből',
|
||||
'tours.walkImport.openSource': 'Megnyitás a Google Mapsben',
|
||||
'tours.walkImport.recalculated':
|
||||
'A TREK újratervezi a megállók közötti utat; a Google Mapsben rajzolt vonalat nem veszi át.',
|
||||
'tours.walkImport.noPosition': 'Nincs hely a linkben',
|
||||
'tours.walkImport.unresolved': '{count} megállónak nincs helye a linkben, ezért kimarad.',
|
||||
'tours.walkImport.unresolved.one': '{count} megállónak nincs helye a linkben, ezért kimarad.',
|
||||
'tours.walkImport.tooFewPositions': 'Egy sétához legalább két megálló kell, amelynek van helye.',
|
||||
'tours.walkImport.replacesDraft': 'A séta betöltése felülírja a tervező mentetlen módosításait.',
|
||||
'tours.walkImport.defaultName': 'Séta a Google Mapsből',
|
||||
'tours.walkImport.draftNotice':
|
||||
'Egy Google Maps gyalogos útvonalából. A megállók közötti utat a TREK maga tervezte meg.',
|
||||
'tours.walkImport.offline': 'A link beolvasásához kapcsolat kell.',
|
||||
'tours.walkImport.failed': 'A linket most nem sikerült beolvasni. Próbáld újra egy kicsit később.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Google Maps-útvonallinket használj: egy maps.app.goo.gl rövid linket vagy egy google.com/maps/dir linket.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Ez a Google Maps-link nem útvonal. Tervezd meg a sétát a Google Mapsben, és oszd meg az útvonalat.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Ez az útvonal nem gyalogosan van tervezve. Állítsd a Google Mapset gyaloglásra, és oszd meg újra az útvonalat.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'A linkből nem derül ki egyértelműen, hogy az útvonal gyalogos. Válaszd a gyaloglást a Google Mapsben, és oszd meg újra.',
|
||||
'tours.walkImport.refusal.short_link_refused': 'A rövid link nem a Google Mapsre vezet, ezért nem követtük.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'A rövid linket most nem sikerült követni. Próbáld újra, vagy illeszd be a hosszú linket.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Ebből a linkből kettőnél kevesebb megállót sikerült beolvasni.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'Ennek az útvonalnak több megállója van, mint amennyit egy séta elbír.',
|
||||
'tours.provenance.takenOverFrom': 'Átvéve innen',
|
||||
'tours.poi.title': 'A túra mentén',
|
||||
'tours.poi.intro': 'Megállóhelyek az útvonal közelében. Semmi sem változik, amíg hozzá nem adsz egyet.',
|
||||
|
||||
@@ -93,7 +93,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Rencanakan tur',
|
||||
'tours.planner.newTour': 'Rencanakan tur baru',
|
||||
'tours.planner.addTour': 'Tambah tur',
|
||||
'tours.planner.addTourHint': 'Rencanakan tur baru, impor file GPX, atau ambil tur dari perjalanan lain',
|
||||
'tours.planner.addTourHint':
|
||||
'Rencanakan tur baru, impor file GPX atau rute jalan kaki dari Google Maps, atau ambil tur dari perjalanan lain',
|
||||
'tours.planner.neutralTitle': 'Rencanakan tur',
|
||||
'tours.planner.neutralBody': 'Mulai tur baru dan klik peta untuk menempatkan titik-titiknya.',
|
||||
'tours.planner.newTitle': 'Tur baru',
|
||||
@@ -307,6 +308,45 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Ambil',
|
||||
'tours.library.taken': '“{name}” diambil ke perjalanan ini.',
|
||||
'tours.library.failed': 'Tur tidak dapat diambil.',
|
||||
'tours.walkImport.menu': 'Impor rute jalan kaki dari Google Maps',
|
||||
'tours.walkImport.title': 'Rute jalan kaki dari tautan Google Maps',
|
||||
'tours.walkImport.intro':
|
||||
'Bagikan rute jalan kaki di Google Maps lalu tempel tautannya di sini. TREK membaca titik singgahnya dan merencanakan sendiri jalan kaki di antaranya.',
|
||||
'tours.walkImport.urlLabel': 'Tautan ke rute jalan kaki',
|
||||
'tours.walkImport.urlHint': 'Tautan pendek maps.app.goo.gl, atau alamat rute di google.com/maps.',
|
||||
'tours.walkImport.read': 'Baca tautan',
|
||||
'tours.walkImport.back': 'Pakai tautan lain',
|
||||
'tours.walkImport.load': 'Muat sebagai rute jalan kaki',
|
||||
'tours.walkImport.stops': 'Titik singgah dalam tautan',
|
||||
'tours.walkImport.source': 'Dari Google Maps',
|
||||
'tours.walkImport.openSource': 'Buka di Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK merencanakan ulang jalan di antara titik singgah; garis yang digambar di Google Maps tidak diambil.',
|
||||
'tours.walkImport.noPosition': 'Tidak ada posisi dalam tautan',
|
||||
'tours.walkImport.unresolved': '{count} titik singgah tidak punya posisi dalam tautan dan dilewati.',
|
||||
'tours.walkImport.tooFewPositions': 'Rute jalan kaki memerlukan setidaknya dua titik singgah yang punya posisi.',
|
||||
'tours.walkImport.replacesDraft':
|
||||
'Memuat rute jalan kaki akan menggantikan perubahan yang belum disimpan di perencana.',
|
||||
'tours.walkImport.defaultName': 'Rute jalan kaki dari Google Maps',
|
||||
'tours.walkImport.draftNotice':
|
||||
'Dari rute jalan kaki Google Maps. Jalan di antara titik singgah direncanakan sendiri oleh TREK.',
|
||||
'tours.walkImport.offline': 'Membaca tautan memerlukan koneksi.',
|
||||
'tours.walkImport.failed': 'Tautan belum bisa dibaca saat ini. Coba lagi sebentar lagi.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Gunakan tautan rute Google Maps: tautan pendek maps.app.goo.gl atau tautan google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Tautan Google Maps ini bukan rute. Rencanakan jalan kaki di Google Maps lalu bagikan rutenya.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Rute ini tidak direncanakan untuk jalan kaki. Ganti Google Maps ke jalan kaki lalu bagikan rutenya lagi.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'Tautan tidak menyatakan dengan jelas bahwa rutenya jalan kaki. Pilih jalan kaki di Google Maps lalu bagikan lagi.',
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
'Tautan pendek mengarah ke tempat selain Google Maps, jadi tidak diikuti.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Tautan pendek belum bisa diikuti saat ini. Coba lagi, atau tempel tautan panjangnya.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Kurang dari dua titik singgah yang bisa dibaca dari tautan itu.',
|
||||
'tours.walkImport.refusal.too_many_stops':
|
||||
'Rute ini punya lebih banyak titik singgah daripada yang muat dalam satu rute jalan kaki.',
|
||||
'tours.provenance.takenOverFrom': 'Diambil dari',
|
||||
'tours.poi.title': 'Di sepanjang tur',
|
||||
'tours.poi.intro': 'Tempat untuk berhenti, dekat rute. Tidak ada yang berubah sampai kamu menambahkannya.',
|
||||
|
||||
@@ -97,7 +97,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': "Pianifica un'escursione",
|
||||
'tours.planner.newTour': 'Pianifica nuova escursione',
|
||||
'tours.planner.addTour': 'Aggiungi giro',
|
||||
'tours.planner.addTourHint': 'Pianifica un nuovo giro, importa file GPX o riprendi un giro da un altro viaggio',
|
||||
'tours.planner.addTourHint':
|
||||
'Pianifica un nuovo giro, importa file GPX o una passeggiata da Google Maps, o riprendi un giro da un altro viaggio',
|
||||
'tours.planner.neutralTitle': "Pianifica un'escursione",
|
||||
'tours.planner.neutralBody': 'Inizia un nuovo percorso e fai clic sulla mappa per posizionarne i punti.',
|
||||
'tours.planner.newTitle': 'Nuova escursione',
|
||||
@@ -315,6 +316,45 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Riprendi',
|
||||
'tours.library.taken': '“{name}” è stato ripreso in questo viaggio.',
|
||||
'tours.library.failed': 'Non è stato possibile riprendere il percorso.',
|
||||
'tours.walkImport.menu': 'Importa passeggiata da Google Maps',
|
||||
'tours.walkImport.title': 'Passeggiata da un link di Google Maps',
|
||||
'tours.walkImport.intro':
|
||||
'Condividi un percorso a piedi in Google Maps e incolla qui il suo link. TREK legge le tappe e traccia da sé il cammino a piedi tra di esse.',
|
||||
'tours.walkImport.urlLabel': 'Link al percorso a piedi',
|
||||
'tours.walkImport.urlHint': "Un link breve maps.app.goo.gl oppure l'indirizzo del percorso su google.com/maps.",
|
||||
'tours.walkImport.read': 'Leggi il link',
|
||||
'tours.walkImport.back': 'Usa un altro link',
|
||||
'tours.walkImport.load': 'Carica come passeggiata',
|
||||
'tours.walkImport.stops': 'Tappe nel link',
|
||||
'tours.walkImport.source': 'Da Google Maps',
|
||||
'tours.walkImport.openSource': 'Apri in Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK traccia di nuovo il cammino tra le tappe; la linea disegnata in Google Maps non viene ripresa.',
|
||||
'tours.walkImport.noPosition': 'Nessuna posizione nel link',
|
||||
'tours.walkImport.unresolved': '{count} tappe non hanno una posizione nel link e vengono tralasciate.',
|
||||
'tours.walkImport.unresolved.one': '{count} tappa non ha una posizione nel link e viene tralasciata.',
|
||||
'tours.walkImport.tooFewPositions': 'Una passeggiata richiede almeno due tappe con una posizione.',
|
||||
'tours.walkImport.replacesDraft': 'Caricare la passeggiata sostituisce le modifiche non salvate nel pianificatore.',
|
||||
'tours.walkImport.defaultName': 'Passeggiata da Google Maps',
|
||||
'tours.walkImport.draftNotice':
|
||||
'Da un percorso a piedi di Google Maps. TREK ha tracciato da sé il cammino tra le tappe.',
|
||||
'tours.walkImport.offline': 'Per leggere un link serve una connessione.',
|
||||
'tours.walkImport.failed': 'Il link non si è potuto leggere adesso. Riprova tra poco.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Usa un link di percorso di Google Maps: un link breve maps.app.goo.gl o un link google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Questo link di Google Maps non è un percorso. Pianifica la passeggiata in Google Maps e condividi il percorso.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Questo percorso non è pianificato a piedi. Passa Google Maps a piedi e condividi di nuovo il percorso.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'Il link non dice chiaramente che il percorso è a piedi. Scegli a piedi in Google Maps e condividilo di nuovo.',
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
'Il link breve porta altrove rispetto a Google Maps, quindi non è stato seguito.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Il link breve non si è potuto seguire adesso. Riprova oppure incolla il link lungo.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Da questo link si sono potute leggere meno di due tappe.',
|
||||
'tours.walkImport.refusal.too_many_stops':
|
||||
'Questo percorso ha più tappe di quante una passeggiata ne possa contenere.',
|
||||
'tours.provenance.takenOverFrom': 'Ripreso da',
|
||||
'tours.poi.title': 'Lungo il percorso',
|
||||
'tours.poi.intro': 'Luoghi per una sosta, vicino al percorso. Nulla cambia finché non ne aggiungi uno.',
|
||||
|
||||
@@ -92,7 +92,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'ツアーを計画',
|
||||
'tours.planner.newTour': '新しいツアーを計画',
|
||||
'tours.planner.addTour': 'ツアーを追加',
|
||||
'tours.planner.addTourHint': '新しいツアーを計画、GPX ファイルを読み込み、または別の旅行からツアーを引き継ぎます',
|
||||
'tours.planner.addTourHint':
|
||||
'新しいツアーを計画、GPX ファイルや Google Maps の街歩きを読み込み、または別の旅行からツアーを引き継ぎます',
|
||||
'tours.planner.neutralTitle': 'ツアーを計画',
|
||||
'tours.planner.neutralBody': '新しいツアーを始めて、地図をクリックして経由地点を置きます。',
|
||||
'tours.planner.newTitle': '新しいツアー',
|
||||
@@ -301,6 +302,41 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': '取り込む',
|
||||
'tours.library.taken': '「{name}」をこの旅に取り込みました。',
|
||||
'tours.library.failed': 'ツアーを取り込めませんでした。',
|
||||
'tours.walkImport.menu': 'Google Maps から街歩きを読み込む',
|
||||
'tours.walkImport.title': 'Google Maps のリンクから街歩き',
|
||||
'tours.walkImport.intro':
|
||||
'Google Maps で徒歩ルートを共有し、そのリンクをここに貼り付けてください。TREK が立ち寄り先を読み取り、その間の徒歩の道を自分で計画します。',
|
||||
'tours.walkImport.urlLabel': '徒歩ルートのリンク',
|
||||
'tours.walkImport.urlHint': 'maps.app.goo.gl の短縮リンク、または google.com/maps 上のルートのアドレス。',
|
||||
'tours.walkImport.read': 'リンクを読み取る',
|
||||
'tours.walkImport.back': '別のリンクを使う',
|
||||
'tours.walkImport.load': '街歩きとして読み込む',
|
||||
'tours.walkImport.stops': 'リンク内の立ち寄り先',
|
||||
'tours.walkImport.source': 'Google Maps から',
|
||||
'tours.walkImport.openSource': 'Google Maps で開く',
|
||||
'tours.walkImport.recalculated':
|
||||
'立ち寄り先の間の道は TREK があらためて計画します。Google Maps で描かれた線は引き継ぎません。',
|
||||
'tours.walkImport.noPosition': 'リンクに位置がありません',
|
||||
'tours.walkImport.unresolved': '{count} 件の立ち寄り先はリンクに位置がないため、除外されます。',
|
||||
'tours.walkImport.tooFewPositions': '街歩きには位置のある立ち寄り先が少なくとも 2 件必要です。',
|
||||
'tours.walkImport.replacesDraft': '街歩きを読み込むと、プランナーの未保存の変更は置き換えられます。',
|
||||
'tours.walkImport.defaultName': 'Google Maps からの街歩き',
|
||||
'tours.walkImport.draftNotice': 'Google Maps の徒歩ルートから。立ち寄り先の間の道は TREK が自分で計画しました。',
|
||||
'tours.walkImport.offline': 'リンクを読み取るには接続が必要です。',
|
||||
'tours.walkImport.failed': 'いまはリンクを読み取れませんでした。少し待ってからもう一度お試しください。',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Google Maps のルートのリンクを使ってください。maps.app.goo.gl の短縮リンクか google.com/maps/dir のリンクです。',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'この Google Maps のリンクはルートではありません。Google Maps で街歩きを計画し、ルートを共有してください。',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'このルートは徒歩で計画されていません。Google Maps を徒歩に切り替えて、ルートをもう一度共有してください。',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'リンクからは徒歩のルートかどうかがはっきりしません。Google Maps で徒歩を選び、もう一度共有してください。',
|
||||
'tours.walkImport.refusal.short_link_refused': '短縮リンクの行き先が Google Maps ではないため、たどりませんでした。',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'いまは短縮リンクをたどれませんでした。もう一度試すか、長いリンクを貼り付けてください。',
|
||||
'tours.walkImport.refusal.too_few_stops': 'このリンクから読み取れた立ち寄り先は 2 件未満でした。',
|
||||
'tours.walkImport.refusal.too_many_stops': 'このルートの立ち寄り先は、街歩きに入る数を超えています。',
|
||||
'tours.provenance.takenOverFrom': '取り込み元',
|
||||
'tours.poi.title': 'ツアー沿い',
|
||||
'tours.poi.intro': 'ルートの近くにある立ち寄り先です。追加するまで何も変わりません。',
|
||||
|
||||
@@ -89,7 +89,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': '투어 계획',
|
||||
'tours.planner.newTour': '새 투어 계획',
|
||||
'tours.planner.addTour': '투어 추가',
|
||||
'tours.planner.addTourHint': '새 투어를 계획하거나 GPX 파일을 가져오거나 다른 여행의 투어를 가져옵니다',
|
||||
'tours.planner.addTourHint':
|
||||
'새 투어를 계획하거나 GPX 파일이나 Google Maps 산책을 가져오거나 다른 여행의 투어를 가져옵니다',
|
||||
'tours.planner.neutralTitle': '투어 계획',
|
||||
'tours.planner.neutralBody': '새 투어를 시작하고 지도를 클릭해 경유 지점을 놓으세요.',
|
||||
'tours.planner.newTitle': '새 투어',
|
||||
@@ -299,6 +300,42 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': '가져오기',
|
||||
'tours.library.taken': '“{name}”을(를) 이 여행으로 가져왔습니다.',
|
||||
'tours.library.failed': '투어를 가져오지 못했습니다.',
|
||||
'tours.walkImport.menu': 'Google Maps에서 산책 가져오기',
|
||||
'tours.walkImport.title': 'Google Maps 링크로 만드는 산책',
|
||||
'tours.walkImport.intro':
|
||||
'Google Maps에서 도보 경로를 공유하고 그 링크를 여기에 붙여 넣으세요. TREK이 정류지를 읽고 그 사이의 도보 길을 직접 계획합니다.',
|
||||
'tours.walkImport.urlLabel': '도보 경로 링크',
|
||||
'tours.walkImport.urlHint': 'maps.app.goo.gl 단축 링크 또는 google.com/maps의 경로 주소.',
|
||||
'tours.walkImport.read': '링크 읽기',
|
||||
'tours.walkImport.back': '다른 링크 사용',
|
||||
'tours.walkImport.load': '산책으로 불러오기',
|
||||
'tours.walkImport.stops': '링크 속 정류지',
|
||||
'tours.walkImport.source': 'Google Maps에서',
|
||||
'tours.walkImport.openSource': 'Google Maps에서 열기',
|
||||
'tours.walkImport.recalculated':
|
||||
'정류지 사이의 길은 TREK이 다시 계획합니다. Google Maps에 그려진 선은 가져오지 않습니다.',
|
||||
'tours.walkImport.noPosition': '링크에 위치가 없음',
|
||||
'tours.walkImport.unresolved': '정류지 {count}곳은 링크에 위치가 없어 제외됩니다.',
|
||||
'tours.walkImport.tooFewPositions': '산책에는 위치가 있는 정류지가 두 곳 이상 필요합니다.',
|
||||
'tours.walkImport.replacesDraft': '산책을 불러오면 플래너의 저장하지 않은 변경 사항이 바뀝니다.',
|
||||
'tours.walkImport.defaultName': 'Google Maps에서 가져온 산책',
|
||||
'tours.walkImport.draftNotice':
|
||||
'Google Maps 도보 경로에서 가져왔습니다. 정류지 사이의 길은 TREK이 직접 계획했습니다.',
|
||||
'tours.walkImport.offline': '링크를 읽으려면 연결이 필요합니다.',
|
||||
'tours.walkImport.failed': '지금은 링크를 읽지 못했습니다. 잠시 후 다시 시도하세요.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Google Maps 경로 링크를 사용하세요. maps.app.goo.gl 단축 링크나 google.com/maps/dir 링크입니다.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'이 Google Maps 링크는 경로가 아닙니다. Google Maps에서 산책을 계획하고 경로를 공유하세요.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'이 경로는 도보로 계획되지 않았습니다. Google Maps를 도보로 바꾸고 경로를 다시 공유하세요.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'링크만으로는 도보 경로인지 분명하지 않습니다. Google Maps에서 도보를 고르고 다시 공유하세요.',
|
||||
'tours.walkImport.refusal.short_link_refused': '단축 링크가 Google Maps가 아닌 곳으로 이어져서 따라가지 않았습니다.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'지금은 단축 링크를 따라가지 못했습니다. 다시 시도하거나 긴 링크를 붙여 넣으세요.',
|
||||
'tours.walkImport.refusal.too_few_stops': '이 링크에서 읽은 정류지가 두 곳보다 적습니다.',
|
||||
'tours.walkImport.refusal.too_many_stops': '이 경로에는 산책 하나에 담을 수 있는 것보다 많은 정류지가 있습니다.',
|
||||
'tours.provenance.takenOverFrom': '가져온 곳',
|
||||
'tours.poi.title': '투어 경로 주변',
|
||||
'tours.poi.intro': '경로 근처에 머물 만한 곳입니다. 추가하기 전에는 아무것도 바뀌지 않습니다.',
|
||||
|
||||
@@ -99,7 +99,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Nieuwe tocht plannen',
|
||||
'tours.planner.addTour': 'Tocht toevoegen',
|
||||
'tours.planner.addTourHint':
|
||||
'Een nieuwe tocht plannen, GPX-bestanden importeren of een tocht uit een andere reis overnemen',
|
||||
'Een nieuwe tocht plannen, GPX-bestanden of een wandeling uit Google Maps importeren, of een tocht uit een andere reis overnemen',
|
||||
'tours.planner.neutralTitle': 'Een tocht plannen',
|
||||
'tours.planner.neutralBody': 'Begin een nieuwe tocht en klik op de kaart om de punten te plaatsen.',
|
||||
'tours.planner.newTitle': 'Nieuwe tocht',
|
||||
@@ -316,6 +316,44 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Overnemen',
|
||||
'tours.library.taken': '‘{name}’ is overgenomen in deze reis.',
|
||||
'tours.library.failed': 'De tocht kon niet worden overgenomen.',
|
||||
'tours.walkImport.menu': 'Wandeling uit Google Maps importeren',
|
||||
'tours.walkImport.title': 'Wandeling uit een Google Maps-link',
|
||||
'tours.walkImport.intro':
|
||||
'Deel een wandelroute in Google Maps en plak de link hier. TREK leest de haltes en plant zelf de weg te voet ertussen.',
|
||||
'tours.walkImport.urlLabel': 'Link naar de wandelroute',
|
||||
'tours.walkImport.urlHint': 'Een korte link van maps.app.goo.gl of het adres van de route op google.com/maps.',
|
||||
'tours.walkImport.read': 'Link lezen',
|
||||
'tours.walkImport.back': 'Andere link gebruiken',
|
||||
'tours.walkImport.load': 'Laden als wandeling',
|
||||
'tours.walkImport.stops': 'Haltes in de link',
|
||||
'tours.walkImport.source': 'Uit Google Maps',
|
||||
'tours.walkImport.openSource': 'Openen in Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK plant de weg tussen de haltes opnieuw; de lijn die in Google Maps getekend is, wordt niet overgenomen.',
|
||||
'tours.walkImport.noPosition': 'Geen positie in de link',
|
||||
'tours.walkImport.unresolved': '{count} haltes hebben geen positie in de link en worden weggelaten.',
|
||||
'tours.walkImport.unresolved.one': '{count} halte heeft geen positie in de link en wordt weggelaten.',
|
||||
'tours.walkImport.tooFewPositions': 'Een wandeling heeft minstens twee haltes met een positie nodig.',
|
||||
'tours.walkImport.replacesDraft': 'Het laden van de wandeling vervangt de niet-opgeslagen wijzigingen in de planner.',
|
||||
'tours.walkImport.defaultName': 'Wandeling uit Google Maps',
|
||||
'tours.walkImport.draftNotice':
|
||||
'Uit een wandelroute van Google Maps. TREK heeft de weg tussen de haltes zelf gepland.',
|
||||
'tours.walkImport.offline': 'Voor het lezen van een link is een verbinding nodig.',
|
||||
'tours.walkImport.failed': 'De link kon nu niet worden gelezen. Probeer het zo dadelijk opnieuw.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Gebruik een routelink van Google Maps: een korte link van maps.app.goo.gl of een link op google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Deze Google Maps-link is geen route. Plan de wandeling in Google Maps en deel de route.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Deze route is niet te voet gepland. Zet Google Maps op lopen en deel de route opnieuw.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'De link zegt niet duidelijk dat de route te voet is. Kies lopen in Google Maps en deel haar opnieuw.',
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
'De korte link leidt ergens anders heen dan Google Maps en is daarom niet gevolgd.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'De korte link kon nu niet worden gevolgd. Probeer het opnieuw of plak de lange link.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Uit deze link konden minder dan twee haltes worden gelezen.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'Deze route heeft meer haltes dan een wandeling kan bevatten.',
|
||||
'tours.provenance.takenOverFrom': 'Overgenomen uit',
|
||||
'tours.poi.title': 'Langs de tocht',
|
||||
'tours.poi.intro': 'Plekken voor een stop, dicht bij de route. Er verandert niets tot je er een toevoegt.',
|
||||
|
||||
@@ -105,7 +105,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Zaplanuj wycieczkę',
|
||||
'tours.planner.newTour': 'Zaplanuj nową wycieczkę',
|
||||
'tours.planner.addTour': 'Dodaj trasę',
|
||||
'tours.planner.addTourHint': 'Zaplanuj nową trasę, zaimportuj pliki GPX lub przejmij trasę z innej podróży',
|
||||
'tours.planner.addTourHint':
|
||||
'Zaplanuj nową trasę, zaimportuj pliki GPX lub spacer z Google Maps albo przejmij trasę z innej podróży',
|
||||
'tours.planner.neutralTitle': 'Zaplanuj wycieczkę',
|
||||
'tours.planner.neutralBody': 'Rozpocznij nową trasę i kliknij mapę, aby wyznaczyć jej punkty.',
|
||||
'tours.planner.newTitle': 'Nowa wycieczka',
|
||||
@@ -322,6 +323,45 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Przejmij',
|
||||
'tours.library.taken': '„{name}” przejęto do tej podróży.',
|
||||
'tours.library.failed': 'Nie udało się przejąć trasy.',
|
||||
'tours.walkImport.menu': 'Importuj spacer z Google Maps',
|
||||
'tours.walkImport.title': 'Spacer z linku Google Maps',
|
||||
'tours.walkImport.intro':
|
||||
'Udostępnij trasę pieszą w Google Maps i wklej tu jej link. TREK odczyta przystanki i sam wyznaczy drogę pieszą między nimi.',
|
||||
'tours.walkImport.urlLabel': 'Link do trasy pieszej',
|
||||
'tours.walkImport.urlHint': 'Krótki link maps.app.goo.gl albo adres trasy w google.com/maps.',
|
||||
'tours.walkImport.read': 'Odczytaj link',
|
||||
'tours.walkImport.back': 'Użyj innego linku',
|
||||
'tours.walkImport.load': 'Wczytaj jako spacer',
|
||||
'tours.walkImport.stops': 'Przystanki w linku',
|
||||
'tours.walkImport.source': 'Z Google Maps',
|
||||
'tours.walkImport.openSource': 'Otwórz w Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK wyznacza drogę między przystankami od nowa; linia narysowana w Google Maps nie jest przejmowana.',
|
||||
'tours.walkImport.noPosition': 'Brak położenia w linku',
|
||||
'tours.walkImport.unresolved': '{count} przystanku nie ma położenia w linku i zostaje pominięte.',
|
||||
'tours.walkImport.unresolved.one': '{count} przystanek nie ma położenia w linku i zostaje pominięty.',
|
||||
'tours.walkImport.unresolved.few': '{count} przystanki nie mają położenia w linku i zostają pominięte.',
|
||||
'tours.walkImport.unresolved.many': '{count} przystanków nie ma położenia w linku i zostaje pominiętych.',
|
||||
'tours.walkImport.tooFewPositions': 'Spacer potrzebuje co najmniej dwóch przystanków z położeniem.',
|
||||
'tours.walkImport.replacesDraft': 'Wczytanie spaceru zastąpi niezapisane zmiany w planerze.',
|
||||
'tours.walkImport.defaultName': 'Spacer z Google Maps',
|
||||
'tours.walkImport.draftNotice': 'Z trasy pieszej w Google Maps. Drogę między przystankami TREK wyznaczył sam.',
|
||||
'tours.walkImport.offline': 'Odczytanie linku wymaga połączenia.',
|
||||
'tours.walkImport.failed': 'Nie udało się teraz odczytać linku. Spróbuj ponownie za chwilę.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Użyj linku do trasy w Google Maps: krótkiego linku maps.app.goo.gl albo linku google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Ten link Google Maps nie jest trasą. Zaplanuj spacer w Google Maps i udostępnij trasę.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Ta trasa nie jest zaplanowana pieszo. Przełącz Google Maps na pieszo i udostępnij trasę ponownie.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'Link nie mówi jasno, że trasa jest piesza. Wybierz pieszo w Google Maps i udostępnij ją ponownie.',
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
'Krótki link prowadzi gdzie indziej niż do Google Maps, więc nie został otwarty.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Nie udało się teraz otworzyć krótkiego linku. Spróbuj ponownie albo wklej długi link.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Z tego linku udało się odczytać mniej niż dwa przystanki.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'Ta trasa ma więcej przystanków, niż zmieści spacer.',
|
||||
'tours.provenance.takenOverFrom': 'Przejęto z',
|
||||
'tours.poi.title': 'Wzdłuż trasy',
|
||||
'tours.poi.intro': 'Miejsca na postój blisko trasy. Nic się nie zmienia, dopóki nie dodasz któregoś.',
|
||||
|
||||
@@ -106,7 +106,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Спланировать новый тур',
|
||||
'tours.planner.addTour': 'Добавить маршрут',
|
||||
'tours.planner.addTourHint':
|
||||
'Спланировать новый маршрут, импортировать файлы GPX или перенести маршрут из другой поездки',
|
||||
'Спланировать новый маршрут, импортировать файлы GPX или прогулку из Google Maps, или перенести маршрут из другой поездки',
|
||||
'tours.planner.neutralTitle': 'Спланировать тур',
|
||||
'tours.planner.neutralBody': 'Начните новый маршрут и нажимайте на карту, чтобы расставить точки.',
|
||||
'tours.planner.newTitle': 'Новый тур',
|
||||
@@ -326,6 +326,45 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Взять',
|
||||
'tours.library.taken': '«{name}» добавлен в эту поездку.',
|
||||
'tours.library.failed': 'Не удалось взять маршрут.',
|
||||
'tours.walkImport.menu': 'Импортировать прогулку из Google Maps',
|
||||
'tours.walkImport.title': 'Прогулка по ссылке Google Maps',
|
||||
'tours.walkImport.intro':
|
||||
'Поделись пешим маршрутом в Google Maps и вставь сюда его ссылку. TREK прочитает остановки и сам проложит пеший путь между ними.',
|
||||
'tours.walkImport.urlLabel': 'Ссылка на пеший маршрут',
|
||||
'tours.walkImport.urlHint': 'Короткая ссылка maps.app.goo.gl или адрес маршрута на google.com/maps.',
|
||||
'tours.walkImport.read': 'Прочитать ссылку',
|
||||
'tours.walkImport.back': 'Взять другую ссылку',
|
||||
'tours.walkImport.load': 'Загрузить как прогулку',
|
||||
'tours.walkImport.stops': 'Остановки в ссылке',
|
||||
'tours.walkImport.source': 'Из Google Maps',
|
||||
'tours.walkImport.openSource': 'Открыть в Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK заново прокладывает путь между остановками; линия, нарисованная в Google Maps, не переносится.',
|
||||
'tours.walkImport.noPosition': 'В ссылке нет координат',
|
||||
'tours.walkImport.unresolved': 'У {count} остановки нет координат в ссылке, они не войдут в прогулку.',
|
||||
'tours.walkImport.unresolved.one': 'У {count} остановки нет координат в ссылке, она не войдёт в прогулку.',
|
||||
'tours.walkImport.unresolved.few': 'У {count} остановок нет координат в ссылке, они не войдут в прогулку.',
|
||||
'tours.walkImport.unresolved.many': 'У {count} остановок нет координат в ссылке, они не войдут в прогулку.',
|
||||
'tours.walkImport.tooFewPositions': 'Для прогулки нужны хотя бы две остановки с координатами.',
|
||||
'tours.walkImport.replacesDraft': 'Загрузка прогулки заменит несохранённые изменения в планировщике.',
|
||||
'tours.walkImport.defaultName': 'Прогулка из Google Maps',
|
||||
'tours.walkImport.draftNotice': 'Из пешего маршрута Google Maps. Путь между остановками TREK проложил сам.',
|
||||
'tours.walkImport.offline': 'Чтобы прочитать ссылку, нужно подключение.',
|
||||
'tours.walkImport.failed': 'Не удалось прочитать ссылку сейчас. Попробуй ещё раз чуть позже.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Используй ссылку на маршрут Google Maps: короткую ссылку maps.app.goo.gl или ссылку google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Эта ссылка Google Maps не маршрут. Спланируй прогулку в Google Maps и поделись маршрутом.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Этот маршрут проложен не пешком. Переключи Google Maps на пеший режим и поделись маршрутом снова.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'Из ссылки не ясно, что маршрут пеший. Выбери пеший режим в Google Maps и поделись им снова.',
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
'Короткая ссылка ведёт не в Google Maps, поэтому по ней не переходили.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Не удалось перейти по короткой ссылке сейчас. Попробуй ещё раз или вставь длинную ссылку.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Из этой ссылки удалось прочитать меньше двух остановок.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'В этом маршруте больше остановок, чем вмещает прогулка.',
|
||||
'tours.provenance.takenOverFrom': 'Взят из',
|
||||
'tours.poi.title': 'Вдоль маршрута',
|
||||
'tours.poi.intro': 'Места для остановки рядом с маршрутом. Ничего не меняется, пока вы не добавите одно из них.',
|
||||
|
||||
@@ -99,7 +99,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Naplánovať túru',
|
||||
'tours.planner.newTour': 'Naplánovať novú túru',
|
||||
'tours.planner.addTour': 'Pridať túru',
|
||||
'tours.planner.addTourHint': 'Naplánuj novú túru, importuj súbory GPX alebo prevezmi túru z inej cesty',
|
||||
'tours.planner.addTourHint':
|
||||
'Naplánuj novú túru, importuj súbory GPX alebo prechádzku z Google Maps, alebo prevezmi túru z inej cesty',
|
||||
'tours.planner.neutralTitle': 'Naplánovať túru',
|
||||
'tours.planner.neutralBody': 'Začnite novú túru a kliknutím do mapy umiestnite jej body.',
|
||||
'tours.planner.newTitle': 'Nová túra',
|
||||
@@ -309,6 +310,43 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Prevziať',
|
||||
'tours.library.taken': '„{name}“ bola prevzatá do tejto cesty.',
|
||||
'tours.library.failed': 'Túru sa nepodarilo prevziať.',
|
||||
'tours.walkImport.menu': 'Importovať prechádzku z Google Maps',
|
||||
'tours.walkImport.title': 'Prechádzka z odkazu Google Maps',
|
||||
'tours.walkImport.intro':
|
||||
'Zdieľaj pešiu trasu v Google Maps a vlož sem jej odkaz. TREK prečíta zastávky a pešiu cestu medzi nimi naplánuje sám.',
|
||||
'tours.walkImport.urlLabel': 'Odkaz na pešiu trasu',
|
||||
'tours.walkImport.urlHint': 'Krátky odkaz maps.app.goo.gl alebo adresa trasy na google.com/maps.',
|
||||
'tours.walkImport.read': 'Prečítať odkaz',
|
||||
'tours.walkImport.back': 'Použiť iný odkaz',
|
||||
'tours.walkImport.load': 'Načítať ako prechádzku',
|
||||
'tours.walkImport.stops': 'Zastávky v odkaze',
|
||||
'tours.walkImport.source': 'Z Google Maps',
|
||||
'tours.walkImport.openSource': 'Otvoriť v Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK naplánuje cestu medzi zastávkami nanovo; čiara nakreslená v Google Maps sa nepreberá.',
|
||||
'tours.walkImport.noPosition': 'V odkaze chýba poloha',
|
||||
'tours.walkImport.unresolved': '{count} zastávok nemá v odkaze polohu, preto ich vynecháme.',
|
||||
'tours.walkImport.unresolved.one': '{count} zastávka nemá v odkaze polohu a vynecháva sa.',
|
||||
'tours.walkImport.unresolved.few': '{count} zastávky nemajú v odkaze polohu a vynechávajú sa.',
|
||||
'tours.walkImport.tooFewPositions': 'Prechádzka potrebuje aspoň dve zastávky s polohou.',
|
||||
'tours.walkImport.replacesDraft': 'Načítanie prechádzky nahradí neuložené zmeny v plánovači.',
|
||||
'tours.walkImport.defaultName': 'Prechádzka z Google Maps',
|
||||
'tours.walkImport.draftNotice': 'Z pešej trasy v Google Maps. Cestu medzi zastávkami naplánoval TREK sám.',
|
||||
'tours.walkImport.offline': 'Na čítanie odkazu treba pripojenie.',
|
||||
'tours.walkImport.failed': 'Odkaz sa teraz nepodarilo prečítať. Skús to o chvíľu znova.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Použi odkaz na trasu v Google Maps: krátky odkaz maps.app.goo.gl alebo odkaz google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Tento odkaz Google Maps nie je trasa. Naplánuj prechádzku v Google Maps a zdieľaj trasu.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Táto trasa nie je naplánovaná pešo. Prepni Google Maps na chôdzu a zdieľaj trasu znova.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'Z odkazu nie je jasné, že trasa je pešia. Zvoľ v Google Maps chôdzu a zdieľaj ju znova.',
|
||||
'tours.walkImport.refusal.short_link_refused': 'Krátky odkaz vedie inam než do Google Maps, preto sa nesledoval.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Krátky odkaz sa teraz nepodarilo sledovať. Skús to znova alebo vlož dlhý odkaz.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Z tohto odkazu sa podarilo prečítať menej ako dve zastávky.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'Táto trasa má viac zastávok, než prechádzka unesie.',
|
||||
'tours.provenance.takenOverFrom': 'Prevzaté z',
|
||||
'tours.poi.title': 'Pozdĺž túry',
|
||||
'tours.poi.intro': 'Miesta na zastávku blízko trasy. Nič sa nezmení, kým nejaké nepridáš.',
|
||||
|
||||
@@ -95,7 +95,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Planera en tur',
|
||||
'tours.planner.newTour': 'Planera ny tur',
|
||||
'tours.planner.addTour': 'Lägg till tur',
|
||||
'tours.planner.addTourHint': 'Planera en ny tur, importera GPX-filer eller ta över en tur från en annan resa',
|
||||
'tours.planner.addTourHint':
|
||||
'Planera en ny tur, importera GPX-filer eller en promenad från Google Maps, eller ta över en tur från en annan resa',
|
||||
'tours.planner.neutralTitle': 'Planera en tur',
|
||||
'tours.planner.neutralBody': 'Starta en ny tur och klicka på kartan för att placera dess punkter.',
|
||||
'tours.planner.newTitle': 'Ny tur',
|
||||
@@ -306,6 +307,43 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Hämta',
|
||||
'tours.library.taken': '”{name}” hämtades till den här resan.',
|
||||
'tours.library.failed': 'Turen kunde inte hämtas.',
|
||||
'tours.walkImport.menu': 'Importera promenad från Google Maps',
|
||||
'tours.walkImport.title': 'Promenad från en Google Maps-länk',
|
||||
'tours.walkImport.intro':
|
||||
'Dela en gångrutt i Google Maps och klistra in länken här. TREK läser stoppen och planerar själv vägen till fots mellan dem.',
|
||||
'tours.walkImport.urlLabel': 'Länk till gångrutten',
|
||||
'tours.walkImport.urlHint': 'En kortlänk från maps.app.goo.gl eller ruttens adress på google.com/maps.',
|
||||
'tours.walkImport.read': 'Läs länken',
|
||||
'tours.walkImport.back': 'Använd en annan länk',
|
||||
'tours.walkImport.load': 'Läs in som promenad',
|
||||
'tours.walkImport.stops': 'Stopp i länken',
|
||||
'tours.walkImport.source': 'Från Google Maps',
|
||||
'tours.walkImport.openSource': 'Öppna i Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK planerar vägen mellan stoppen på nytt; linjen som ritats i Google Maps tas inte över.',
|
||||
'tours.walkImport.noPosition': 'Ingen position i länken',
|
||||
'tours.walkImport.unresolved': '{count} stopp saknar position i länken och lämnas utanför.',
|
||||
'tours.walkImport.unresolved.one': '{count} stopp saknar position i länken och lämnas utanför.',
|
||||
'tours.walkImport.tooFewPositions': 'En promenad behöver minst två stopp med position.',
|
||||
'tours.walkImport.replacesDraft': 'När promenaden läses in ersätts de osparade ändringarna i planeraren.',
|
||||
'tours.walkImport.defaultName': 'Promenad från Google Maps',
|
||||
'tours.walkImport.draftNotice': 'Från en gångrutt i Google Maps. TREK har själv planerat vägen mellan stoppen.',
|
||||
'tours.walkImport.offline': 'Att läsa en länk kräver en anslutning.',
|
||||
'tours.walkImport.failed': 'Länken kunde inte läsas just nu. Försök igen om en stund.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Använd en ruttlänk från Google Maps: en kortlänk från maps.app.goo.gl eller en länk på google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Den Google Maps-länken är ingen rutt. Planera promenaden i Google Maps och dela rutten.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Den rutten är inte planerad till fots. Ställ om Google Maps till gång och dela rutten igen.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'Länken säger inte tydligt att rutten går till fots. Välj gång i Google Maps och dela den igen.',
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
'Kortlänken leder någon annanstans än till Google Maps och följdes därför inte.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Kortlänken kunde inte följas just nu. Försök igen eller klistra in den långa länken.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Färre än två stopp kunde läsas från den länken.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'Den rutten har fler stopp än en promenad rymmer.',
|
||||
'tours.provenance.takenOverFrom': 'Hämtad från',
|
||||
'tours.poi.title': 'Längs turen',
|
||||
'tours.poi.intro': 'Platser för ett stopp nära rutten. Inget ändras förrän du lägger till en.',
|
||||
|
||||
@@ -88,7 +88,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'วางแผนทัวร์',
|
||||
'tours.planner.newTour': 'วางแผนทัวร์ใหม่',
|
||||
'tours.planner.addTour': 'เพิ่มทริป',
|
||||
'tours.planner.addTourHint': 'วางแผนทริปใหม่ นำเข้าไฟล์ GPX หรือรับทริปมาจากการเดินทางอื่น',
|
||||
'tours.planner.addTourHint':
|
||||
'วางแผนทริปใหม่ นำเข้าไฟล์ GPX หรือทริปเดินเท้าจาก Google Maps หรือรับทริปมาจากการเดินทางอื่น',
|
||||
'tours.planner.neutralTitle': 'วางแผนทัวร์',
|
||||
'tours.planner.neutralBody': 'เริ่มทัวร์ใหม่ แล้วคลิกแผนที่เพื่อวางจุดของเส้นทาง',
|
||||
'tours.planner.newTitle': 'ทัวร์ใหม่',
|
||||
@@ -294,6 +295,39 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'นำมาใช้',
|
||||
'tours.library.taken': 'นำ “{name}” มาใช้ในทริปนี้แล้ว',
|
||||
'tours.library.failed': 'นำทัวร์มาใช้ไม่ได้',
|
||||
'tours.walkImport.menu': 'นำเข้าทริปเดินเท้าจาก Google Maps',
|
||||
'tours.walkImport.title': 'ทริปเดินเท้าจากลิงก์ Google Maps',
|
||||
'tours.walkImport.intro':
|
||||
'แชร์เส้นทางเดินเท้าใน Google Maps แล้ววางลิงก์ไว้ที่นี่ TREK จะอ่านจุดแวะและวางแผนทางเดินระหว่างจุดเหล่านั้นเอง',
|
||||
'tours.walkImport.urlLabel': 'ลิงก์ของเส้นทางเดินเท้า',
|
||||
'tours.walkImport.urlHint': 'ลิงก์สั้นของ maps.app.goo.gl หรือที่อยู่ของเส้นทางบน google.com/maps',
|
||||
'tours.walkImport.read': 'อ่านลิงก์',
|
||||
'tours.walkImport.back': 'ใช้ลิงก์อื่น',
|
||||
'tours.walkImport.load': 'โหลดเป็นทริปเดินเท้า',
|
||||
'tours.walkImport.stops': 'จุดแวะในลิงก์',
|
||||
'tours.walkImport.source': 'จาก Google Maps',
|
||||
'tours.walkImport.openSource': 'เปิดใน Google Maps',
|
||||
'tours.walkImport.recalculated': 'TREK วางแผนทางระหว่างจุดแวะใหม่ เส้นที่วาดไว้ใน Google Maps จะไม่ถูกนำมาใช้',
|
||||
'tours.walkImport.noPosition': 'ไม่มีตำแหน่งในลิงก์',
|
||||
'tours.walkImport.unresolved': 'จุดแวะ {count} จุดไม่มีตำแหน่งในลิงก์และจะถูกข้ามไป',
|
||||
'tours.walkImport.tooFewPositions': 'ทริปเดินเท้าต้องมีจุดแวะที่มีตำแหน่งอย่างน้อยสองจุด',
|
||||
'tours.walkImport.replacesDraft': 'การโหลดทริปเดินเท้าจะแทนที่การเปลี่ยนแปลงที่ยังไม่ได้บันทึกในตัววางแผน',
|
||||
'tours.walkImport.defaultName': 'ทริปเดินเท้าจาก Google Maps',
|
||||
'tours.walkImport.draftNotice': 'มาจากเส้นทางเดินเท้าของ Google Maps ทางระหว่างจุดแวะ TREK วางแผนเอง',
|
||||
'tours.walkImport.offline': 'การอ่านลิงก์ต้องเชื่อมต่ออินเทอร์เน็ต',
|
||||
'tours.walkImport.failed': 'ตอนนี้อ่านลิงก์ไม่ได้ ลองอีกครั้งในอีกสักครู่',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'ใช้ลิงก์เส้นทางของ Google Maps คือลิงก์สั้นของ maps.app.goo.gl หรือลิงก์ google.com/maps/dir',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'ลิงก์ Google Maps นี้ไม่ใช่เส้นทาง วางแผนทริปเดินเท้าใน Google Maps แล้วแชร์เส้นทาง',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'เส้นทางนี้ไม่ได้วางแผนเป็นการเดินเท้า เปลี่ยน Google Maps เป็นโหมดเดินแล้วแชร์เส้นทางอีกครั้ง',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'ลิงก์ไม่ได้บอกชัดว่าเส้นทางนี้เป็นการเดินเท้า เลือกโหมดเดินใน Google Maps แล้วแชร์อีกครั้ง',
|
||||
'tours.walkImport.refusal.short_link_refused': 'ลิงก์สั้นนี้พาไปที่อื่นที่ไม่ใช่ Google Maps จึงไม่ได้ตามไป',
|
||||
'tours.walkImport.refusal.short_link_failed': 'ตอนนี้ตามลิงก์สั้นไม่ได้ ลองอีกครั้งหรือวางลิงก์แบบยาว',
|
||||
'tours.walkImport.refusal.too_few_stops': 'อ่านจุดแวะจากลิงก์นี้ได้ไม่ถึงสองจุด',
|
||||
'tours.walkImport.refusal.too_many_stops': 'เส้นทางนี้มีจุดแวะมากกว่าที่ทริปเดินเท้าหนึ่งทริปรับได้',
|
||||
'tours.provenance.takenOverFrom': 'นำมาจาก',
|
||||
'tours.poi.title': 'ตามเส้นทางทัวร์',
|
||||
'tours.poi.intro': 'สถานที่แวะพักใกล้เส้นทาง จะไม่มีอะไรเปลี่ยนจนกว่าคุณจะเพิ่ม',
|
||||
|
||||
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
|
||||
// placeholder spec; here only the general keys are.
|
||||
const groups = pluralGroups(Object.keys(english));
|
||||
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
|
||||
const GENERAL_KEY_COUNT = 788;
|
||||
const GENERAL_KEY_COUNT = 816;
|
||||
|
||||
function literalKeys(source: string): string[] {
|
||||
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
|
||||
|
||||
@@ -94,7 +94,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Tur planla',
|
||||
'tours.planner.newTour': 'Yeni tur planla',
|
||||
'tours.planner.addTour': 'Tur ekle',
|
||||
'tours.planner.addTourHint': 'Yeni bir tur planla, GPX dosyaları içe aktar veya başka bir seyahatten tur al',
|
||||
'tours.planner.addTourHint':
|
||||
"Yeni bir tur planla, GPX dosyaları ya da Google Maps'ten bir yürüyüş içe aktar veya başka bir seyahatten tur al",
|
||||
'tours.planner.neutralTitle': 'Tur planla',
|
||||
'tours.planner.neutralBody': 'Yeni bir tur başlatın ve noktalarını yerleştirmek için haritaya tıklayın.',
|
||||
'tours.planner.newTitle': 'Yeni tur',
|
||||
@@ -308,6 +309,43 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Al',
|
||||
'tours.library.taken': '“{name}” bu geziye alındı.',
|
||||
'tours.library.failed': 'Tur alınamadı.',
|
||||
'tours.walkImport.menu': "Google Maps'ten yürüyüş içe aktar",
|
||||
'tours.walkImport.title': 'Google Maps bağlantısından yürüyüş',
|
||||
'tours.walkImport.intro':
|
||||
"Google Maps'te bir yürüyüş rotası paylaş ve bağlantısını buraya yapıştır. TREK durakları okur ve aralarındaki yürüme yolunu kendisi planlar.",
|
||||
'tours.walkImport.urlLabel': 'Yürüyüş rotasının bağlantısı',
|
||||
'tours.walkImport.urlHint': 'Bir maps.app.goo.gl kısa bağlantısı ya da rotanın google.com/maps adresi.',
|
||||
'tours.walkImport.read': 'Bağlantıyı oku',
|
||||
'tours.walkImport.back': 'Başka bağlantı kullan',
|
||||
'tours.walkImport.load': 'Yürüyüş olarak yükle',
|
||||
'tours.walkImport.stops': 'Bağlantıdaki duraklar',
|
||||
'tours.walkImport.source': "Google Maps'ten",
|
||||
'tours.walkImport.openSource': "Google Maps'te aç",
|
||||
'tours.walkImport.recalculated':
|
||||
"TREK duraklar arasındaki yolu yeniden planlar; Google Maps'te çizilen çizgi alınmaz.",
|
||||
'tours.walkImport.noPosition': 'Bağlantıda konum yok',
|
||||
'tours.walkImport.unresolved': '{count} durağın bağlantıda konumu yok ve dışarıda bırakılıyor.',
|
||||
'tours.walkImport.unresolved.one': '{count} durağın bağlantıda konumu yok ve dışarıda bırakılıyor.',
|
||||
'tours.walkImport.tooFewPositions': 'Bir yürüyüş için konumu olan en az iki durak gerekir.',
|
||||
'tours.walkImport.replacesDraft': 'Yürüyüşü yüklemek planlayıcıdaki kaydedilmemiş değişikliklerin yerini alır.',
|
||||
'tours.walkImport.defaultName': "Google Maps'ten yürüyüş",
|
||||
'tours.walkImport.draftNotice': 'Bir Google Maps yürüyüş rotasından. Duraklar arasındaki yolu TREK kendisi planladı.',
|
||||
'tours.walkImport.offline': 'Bir bağlantıyı okumak için bağlantı gerekir.',
|
||||
'tours.walkImport.failed': 'Bağlantı şu anda okunamadı. Birazdan yeniden dene.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Bir Google Maps rota bağlantısı kullan: bir maps.app.goo.gl kısa bağlantısı ya da bir google.com/maps/dir bağlantısı.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
"Bu Google Maps bağlantısı bir rota değil. Yürüyüşü Google Maps'te planla ve rotayı paylaş.",
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
"Bu rota yürüyerek planlanmamış. Google Maps'i yürüme moduna al ve rotayı yeniden paylaş.",
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
"Bağlantı, rotanın yürüyerek olduğunu açıkça söylemiyor. Google Maps'te yürümeyi seç ve yeniden paylaş.",
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
'Kısa bağlantı Google Maps dışında bir yere gidiyor, bu yüzden takip edilmedi.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Kısa bağlantı şu anda takip edilemedi. Yeniden dene ya da uzun bağlantıyı yapıştır.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Bu bağlantıdan ikiden az durak okunabildi.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'Bu rotada bir yürüyüşün alabileceğinden fazla durak var.',
|
||||
'tours.provenance.takenOverFrom': 'Alındığı yer',
|
||||
'tours.poi.title': 'Tur boyunca',
|
||||
'tours.poi.intro': 'Rotaya yakın mola yerleri. Birini ekleyene kadar hiçbir şey değişmez.',
|
||||
|
||||
@@ -104,7 +104,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Спланувати тур',
|
||||
'tours.planner.newTour': 'Спланувати новий тур',
|
||||
'tours.planner.addTour': 'Додати маршрут',
|
||||
'tours.planner.addTourHint': 'Спланувати новий маршрут, імпортувати файли GPX або перенести маршрут з іншої подорожі',
|
||||
'tours.planner.addTourHint':
|
||||
'Спланувати новий маршрут, імпортувати файли GPX або прогулянку з Google Maps, або перенести маршрут з іншої подорожі',
|
||||
'tours.planner.neutralTitle': 'Спланувати тур',
|
||||
'tours.planner.neutralBody': 'Почніть новий маршрут і натискайте на карту, щоб розставити точки.',
|
||||
'tours.planner.newTitle': 'Новий тур',
|
||||
@@ -321,6 +322,44 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Взяти',
|
||||
'tours.library.taken': '«{name}» додано в цю подорож.',
|
||||
'tours.library.failed': 'Не вдалося взяти маршрут.',
|
||||
'tours.walkImport.menu': 'Імпортувати прогулянку з Google Maps',
|
||||
'tours.walkImport.title': 'Прогулянка за посиланням Google Maps',
|
||||
'tours.walkImport.intro':
|
||||
'Поділися пішим маршрутом у Google Maps і встав сюди його посилання. TREK прочитає зупинки й сам прокладе пішу дорогу між ними.',
|
||||
'tours.walkImport.urlLabel': 'Посилання на піший маршрут',
|
||||
'tours.walkImport.urlHint': 'Коротке посилання maps.app.goo.gl або адреса маршруту на google.com/maps.',
|
||||
'tours.walkImport.read': 'Прочитати посилання',
|
||||
'tours.walkImport.back': 'Узяти інше посилання',
|
||||
'tours.walkImport.load': 'Завантажити як прогулянку',
|
||||
'tours.walkImport.stops': 'Зупинки в посиланні',
|
||||
'tours.walkImport.source': 'З Google Maps',
|
||||
'tours.walkImport.openSource': 'Відкрити в Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK заново прокладає дорогу між зупинками; лінія, намальована в Google Maps, не переноситься.',
|
||||
'tours.walkImport.noPosition': 'У посиланні немає координат',
|
||||
'tours.walkImport.unresolved': '{count} зупинки не мають координат у посиланні, тож їх пропущено.',
|
||||
'tours.walkImport.unresolved.one': '{count} зупинка не має координат у посиланні, тож її пропущено.',
|
||||
'tours.walkImport.unresolved.few': '{count} зупинки не мають координат у посиланні, тож їх пропущено.',
|
||||
'tours.walkImport.unresolved.many': '{count} зупинок не мають координат у посиланні, тож їх пропущено.',
|
||||
'tours.walkImport.tooFewPositions': 'Для прогулянки потрібні щонайменше дві зупинки з координатами.',
|
||||
'tours.walkImport.replacesDraft': 'Завантаження прогулянки замінить незбережені зміни в планувальнику.',
|
||||
'tours.walkImport.defaultName': 'Прогулянка з Google Maps',
|
||||
'tours.walkImport.draftNotice': 'З пішого маршруту Google Maps. Дорогу між зупинками TREK проклав сам.',
|
||||
'tours.walkImport.offline': "Щоб прочитати посилання, потрібне з'єднання.",
|
||||
'tours.walkImport.failed': 'Не вдалося прочитати посилання зараз. Спробуй ще раз трохи згодом.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Використай посилання на маршрут Google Maps: коротке посилання maps.app.goo.gl або посилання google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Це посилання Google Maps не маршрут. Сплануй прогулянку в Google Maps і поділися маршрутом.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Цей маршрут прокладено не пішки. Перемкни Google Maps на пішохідний режим і поділися маршрутом знову.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'З посилання не ясно, що маршрут піший. Вибери пішохідний режим у Google Maps і поділися ним знову.',
|
||||
'tours.walkImport.refusal.short_link_refused': 'Коротке посилання веде не в Google Maps, тому за ним не переходили.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Не вдалося перейти за коротким посиланням зараз. Спробуй ще раз або встав довге посилання.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'З цього посилання вдалося прочитати менше двох зупинок.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'У цьому маршруті більше зупинок, ніж уміщує прогулянка.',
|
||||
'tours.provenance.takenOverFrom': 'Взято з',
|
||||
'tours.poi.title': 'Уздовж маршруту',
|
||||
'tours.poi.intro': 'Місця для зупинки поруч із маршрутом. Нічого не змінюється, доки ви не додасте одне з них.',
|
||||
|
||||
@@ -95,7 +95,8 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': 'Lên kế hoạch tham quan',
|
||||
'tours.planner.newTour': 'Lên kế hoạch tham quan mới',
|
||||
'tours.planner.addTour': 'Thêm chuyến đi',
|
||||
'tours.planner.addTourHint': 'Lên kế hoạch chuyến mới, nhập tệp GPX hoặc lấy một chuyến từ hành trình khác',
|
||||
'tours.planner.addTourHint':
|
||||
'Lên kế hoạch chuyến mới, nhập tệp GPX hoặc lộ trình đi bộ từ Google Maps, hoặc lấy một chuyến từ hành trình khác',
|
||||
'tours.planner.neutralTitle': 'Lên kế hoạch tham quan',
|
||||
'tours.planner.neutralBody': 'Bắt đầu chuyến đi mới và nhấp vào bản đồ để đặt các điểm.',
|
||||
'tours.planner.newTitle': 'Chuyến tham quan mới',
|
||||
@@ -308,6 +309,42 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': 'Lấy',
|
||||
'tours.library.taken': 'Đã lấy “{name}” vào chuyến đi này.',
|
||||
'tours.library.failed': 'Không lấy được chuyến.',
|
||||
'tours.walkImport.menu': 'Nhập lộ trình đi bộ từ Google Maps',
|
||||
'tours.walkImport.title': 'Lộ trình đi bộ từ liên kết Google Maps',
|
||||
'tours.walkImport.intro':
|
||||
'Chia sẻ một tuyến đi bộ trong Google Maps rồi dán liên kết vào đây. TREK đọc các điểm dừng và tự lên lộ trình đi bộ giữa chúng.',
|
||||
'tours.walkImport.urlLabel': 'Liên kết đến tuyến đi bộ',
|
||||
'tours.walkImport.urlHint': 'Một liên kết rút gọn maps.app.goo.gl, hoặc địa chỉ của tuyến trên google.com/maps.',
|
||||
'tours.walkImport.read': 'Đọc liên kết',
|
||||
'tours.walkImport.back': 'Dùng liên kết khác',
|
||||
'tours.walkImport.load': 'Tải thành lộ trình đi bộ',
|
||||
'tours.walkImport.stops': 'Các điểm dừng trong liên kết',
|
||||
'tours.walkImport.source': 'Từ Google Maps',
|
||||
'tours.walkImport.openSource': 'Mở trong Google Maps',
|
||||
'tours.walkImport.recalculated':
|
||||
'TREK lên lại đường đi giữa các điểm dừng; đường đã vẽ trong Google Maps không được giữ lại.',
|
||||
'tours.walkImport.noPosition': 'Không có vị trí trong liên kết',
|
||||
'tours.walkImport.unresolved': '{count} điểm dừng không có vị trí trong liên kết nên bị bỏ qua.',
|
||||
'tours.walkImport.tooFewPositions': 'Một lộ trình đi bộ cần ít nhất hai điểm dừng có vị trí.',
|
||||
'tours.walkImport.replacesDraft': 'Tải lộ trình đi bộ sẽ thay thế các thay đổi chưa lưu trong trình lập kế hoạch.',
|
||||
'tours.walkImport.defaultName': 'Lộ trình đi bộ từ Google Maps',
|
||||
'tours.walkImport.draftNotice': 'Từ một tuyến đi bộ của Google Maps. Đường đi giữa các điểm dừng do TREK tự lên.',
|
||||
'tours.walkImport.offline': 'Cần có kết nối mạng để đọc liên kết.',
|
||||
'tours.walkImport.failed': 'Hiện chưa đọc được liên kết. Hãy thử lại sau ít phút.',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'Hãy dùng liên kết tuyến đường của Google Maps: liên kết rút gọn maps.app.goo.gl hoặc liên kết google.com/maps/dir.',
|
||||
'tours.walkImport.refusal.not_a_route':
|
||||
'Liên kết Google Maps này không phải là một tuyến đường. Hãy lên lộ trình đi bộ trong Google Maps và chia sẻ tuyến đường.',
|
||||
'tours.walkImport.refusal.not_walking':
|
||||
'Tuyến này không được lên để đi bộ. Hãy chuyển Google Maps sang đi bộ và chia sẻ lại tuyến đường.',
|
||||
'tours.walkImport.refusal.mode_unknown':
|
||||
'Liên kết không nói rõ tuyến này là đi bộ. Hãy chọn đi bộ trong Google Maps và chia sẻ lại.',
|
||||
'tours.walkImport.refusal.short_link_refused':
|
||||
'Liên kết rút gọn dẫn đến nơi khác chứ không phải Google Maps, nên không được mở theo.',
|
||||
'tours.walkImport.refusal.short_link_failed':
|
||||
'Hiện chưa mở theo được liên kết rút gọn. Hãy thử lại hoặc dán liên kết đầy đủ.',
|
||||
'tours.walkImport.refusal.too_few_stops': 'Chỉ đọc được ít hơn hai điểm dừng từ liên kết này.',
|
||||
'tours.walkImport.refusal.too_many_stops': 'Tuyến này có nhiều điểm dừng hơn mức một lộ trình đi bộ chứa được.',
|
||||
'tours.provenance.takenOverFrom': 'Lấy từ',
|
||||
'tours.poi.title': 'Dọc chuyến đi',
|
||||
'tours.poi.intro': 'Những nơi để dừng chân gần lộ trình. Không có gì thay đổi cho đến khi bạn thêm một nơi.',
|
||||
|
||||
@@ -85,7 +85,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': '規劃遊程',
|
||||
'tours.planner.newTour': '規劃新遊程',
|
||||
'tours.planner.addTour': '新增行程',
|
||||
'tours.planner.addTourHint': '規劃新行程、匯入 GPX 檔案,或從其他旅行中接管行程',
|
||||
'tours.planner.addTourHint': '規劃新行程、匯入 GPX 檔案或 Google Maps 漫步,或從其他旅行中接管行程',
|
||||
'tours.planner.neutralTitle': '規劃遊程',
|
||||
'tours.planner.neutralBody': '開始新路線,點選地圖放置路線點。',
|
||||
'tours.planner.newTitle': '新遊程',
|
||||
@@ -284,6 +284,36 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': '引入',
|
||||
'tours.library.taken': '已將「{name}」引入本行程。',
|
||||
'tours.library.failed': '無法引入路線。',
|
||||
'tours.walkImport.menu': '從 Google Maps 匯入漫步',
|
||||
'tours.walkImport.title': '用 Google Maps 連結建立漫步',
|
||||
'tours.walkImport.intro':
|
||||
'在 Google Maps 中分享一條步行路線,並把連結貼到這裡。TREK 會讀取站點,並自行規劃站點之間的步行道路。',
|
||||
'tours.walkImport.urlLabel': '步行路線的連結',
|
||||
'tours.walkImport.urlHint': 'maps.app.goo.gl 短網址,或 google.com/maps 上的路線網址。',
|
||||
'tours.walkImport.read': '讀取連結',
|
||||
'tours.walkImport.back': '換一個連結',
|
||||
'tours.walkImport.load': '作為漫步載入',
|
||||
'tours.walkImport.stops': '連結中的站點',
|
||||
'tours.walkImport.source': '來自 Google Maps',
|
||||
'tours.walkImport.openSource': '在 Google Maps 中開啟',
|
||||
'tours.walkImport.recalculated': '站點之間的道路由 TREK 重新規劃;Google Maps 中畫出的線不會沿用。',
|
||||
'tours.walkImport.noPosition': '連結中沒有位置',
|
||||
'tours.walkImport.unresolved': '有 {count} 個站點在連結中沒有位置,將會略過。',
|
||||
'tours.walkImport.tooFewPositions': '一次漫步至少需要兩個有位置的站點。',
|
||||
'tours.walkImport.replacesDraft': '載入漫步會取代規劃器中尚未儲存的變更。',
|
||||
'tours.walkImport.defaultName': '來自 Google Maps 的漫步',
|
||||
'tours.walkImport.draftNotice': '來自 Google Maps 的步行路線。站點之間的道路由 TREK 自行規劃。',
|
||||
'tours.walkImport.offline': '讀取連結需要網路連線。',
|
||||
'tours.walkImport.failed': '暫時無法讀取這個連結。請稍後再試。',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'請使用 Google Maps 路線連結:maps.app.goo.gl 短網址或 google.com/maps/dir 連結。',
|
||||
'tours.walkImport.refusal.not_a_route': '這個 Google Maps 連結不是路線。請在 Google Maps 中規劃漫步並分享路線。',
|
||||
'tours.walkImport.refusal.not_walking': '這條路線不是以步行規劃的。請把 Google Maps 切換為步行,再分享一次路線。',
|
||||
'tours.walkImport.refusal.mode_unknown': '連結沒有明確表示這是步行路線。請在 Google Maps 中選擇步行,再分享一次。',
|
||||
'tours.walkImport.refusal.short_link_refused': '這個短網址指向 Google Maps 以外的地方,因此沒有跟隨。',
|
||||
'tours.walkImport.refusal.short_link_failed': '暫時無法跟隨這個短網址。請再試一次,或貼上完整的長連結。',
|
||||
'tours.walkImport.refusal.too_few_stops': '從這個連結只讀取到不足兩個站點。',
|
||||
'tours.walkImport.refusal.too_many_stops': '這條路線的站點比一次漫步能容納的更多。',
|
||||
'tours.provenance.takenOverFrom': '引入自',
|
||||
'tours.poi.title': '沿途',
|
||||
'tours.poi.intro': '靠近路線的停留地點。在你新增之前,不會有任何變動。',
|
||||
|
||||
@@ -86,7 +86,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': '规划游览路线',
|
||||
'tours.planner.newTour': '规划新游览路线',
|
||||
'tours.planner.addTour': '添加行程',
|
||||
'tours.planner.addTourHint': '规划新行程、导入 GPX 文件,或从其他旅行中接管行程',
|
||||
'tours.planner.addTourHint': '规划新行程、导入 GPX 文件或 Google Maps 漫步,或从其他旅行中接管行程',
|
||||
'tours.planner.neutralTitle': '规划游览路线',
|
||||
'tours.planner.neutralBody': '开始新路线,点击地图放置路线点。',
|
||||
'tours.planner.newTitle': '新游览路线',
|
||||
@@ -285,6 +285,36 @@ const tours: TranslationStrings = {
|
||||
'tours.library.confirm': '引入',
|
||||
'tours.library.taken': '已将“{name}”引入本行程。',
|
||||
'tours.library.failed': '无法引入路线。',
|
||||
'tours.walkImport.menu': '从 Google Maps 导入漫步',
|
||||
'tours.walkImport.title': '用 Google Maps 链接创建漫步',
|
||||
'tours.walkImport.intro':
|
||||
'在 Google Maps 中分享一条步行路线,并把链接粘贴到这里。TREK 会读取站点,并自行规划站点之间的步行道路。',
|
||||
'tours.walkImport.urlLabel': '步行路线的链接',
|
||||
'tours.walkImport.urlHint': 'maps.app.goo.gl 短链接,或 google.com/maps 上的路线地址。',
|
||||
'tours.walkImport.read': '读取链接',
|
||||
'tours.walkImport.back': '换一个链接',
|
||||
'tours.walkImport.load': '作为漫步载入',
|
||||
'tours.walkImport.stops': '链接中的站点',
|
||||
'tours.walkImport.source': '来自 Google Maps',
|
||||
'tours.walkImport.openSource': '在 Google Maps 中打开',
|
||||
'tours.walkImport.recalculated': '站点之间的道路由 TREK 重新规划;Google Maps 中画出的线不会被沿用。',
|
||||
'tours.walkImport.noPosition': '链接中没有位置',
|
||||
'tours.walkImport.unresolved': '有 {count} 个站点在链接中没有位置,将被略过。',
|
||||
'tours.walkImport.tooFewPositions': '一次漫步至少需要两个有位置的站点。',
|
||||
'tours.walkImport.replacesDraft': '载入漫步会替换规划器中尚未保存的更改。',
|
||||
'tours.walkImport.defaultName': '来自 Google Maps 的漫步',
|
||||
'tours.walkImport.draftNotice': '来自 Google Maps 的步行路线。站点之间的道路由 TREK 自行规划。',
|
||||
'tours.walkImport.offline': '读取链接需要网络连接。',
|
||||
'tours.walkImport.failed': '暂时无法读取该链接。请稍后再试。',
|
||||
'tours.walkImport.refusal.not_google_maps':
|
||||
'请使用 Google Maps 路线链接:maps.app.goo.gl 短链接或 google.com/maps/dir 链接。',
|
||||
'tours.walkImport.refusal.not_a_route': '这个 Google Maps 链接不是路线。请在 Google Maps 中规划漫步并分享路线。',
|
||||
'tours.walkImport.refusal.not_walking': '这条路线不是按步行规划的。请把 Google Maps 切换为步行,再分享一次路线。',
|
||||
'tours.walkImport.refusal.mode_unknown': '链接没有明确表明这是步行路线。请在 Google Maps 中选择步行,再分享一次。',
|
||||
'tours.walkImport.refusal.short_link_refused': '该短链接指向 Google Maps 以外的地方,因此没有跟随。',
|
||||
'tours.walkImport.refusal.short_link_failed': '暂时无法跟随该短链接。请再试一次,或粘贴完整的长链接。',
|
||||
'tours.walkImport.refusal.too_few_stops': '从该链接只读取到不足两个站点。',
|
||||
'tours.walkImport.refusal.too_many_stops': '这条路线的站点比一次漫步能容纳的更多。',
|
||||
'tours.provenance.takenOverFrom': '引入自',
|
||||
'tours.poi.title': '沿途',
|
||||
'tours.poi.intro': '靠近路线的停留地点。在你添加之前,不会有任何改动。',
|
||||
|
||||
@@ -3,10 +3,16 @@ import {
|
||||
tourVisitListResponseSchema,
|
||||
tourVisitSchema,
|
||||
tourVisitUpdateRequestSchema,
|
||||
WALK_IMPORT_REFUSALS,
|
||||
WALK_IMPORT_STATIONS_MAX,
|
||||
WALK_IMPORT_URL_MAX,
|
||||
walkImportPreviewRequestSchema,
|
||||
walkImportPreviewResponseSchema,
|
||||
walkImportRefusalResponseSchema,
|
||||
walkImportRefusalSchema,
|
||||
walkImportStationSchema,
|
||||
} from './tour-walk.schema';
|
||||
import { TOUR_STATION_NAME_MAX } from './tours.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -64,4 +70,45 @@ describe('city walk import preview contract (CW-06, CW-07)', () => {
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('holds a stop name to what a station takes, and its position to the globe', () => {
|
||||
expect(walkImportStationSchema.safeParse({ name: 'x'.repeat(TOUR_STATION_NAME_MAX), lat: 1, lng: 2 }).success).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
walkImportStationSchema.safeParse({ name: 'x'.repeat(TOUR_STATION_NAME_MAX + 1), lat: 1, lng: 2 }).success,
|
||||
).toBe(false);
|
||||
expect(walkImportStationSchema.safeParse({ name: 'X', lat: 0, lng: 181 }).success).toBe(false);
|
||||
expect(walkImportStationSchema.safeParse({ name: 'X', lat: -91, lng: 0 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('answers two stops at least, the most a link may bring at most, and the link it read', () => {
|
||||
const stop = { name: 'Stop', lat: 41.14, lng: -8.61 };
|
||||
const answer = (count: number, resolved_url = 'https://www.google.com/maps/dir/A/B') =>
|
||||
walkImportPreviewResponseSchema.safeParse({ stations: Array.from({ length: count }, () => stop), resolved_url });
|
||||
expect(answer(2).success).toBe(true);
|
||||
expect(answer(WALK_IMPORT_STATIONS_MAX).success).toBe(true);
|
||||
expect(answer(1).success).toBe(false);
|
||||
expect(answer(WALK_IMPORT_STATIONS_MAX + 1).success).toBe(false);
|
||||
expect(answer(2, 'not a link').success).toBe(false);
|
||||
});
|
||||
|
||||
it('names every reason a link is refused for, and a refusal carries its text and its reason', () => {
|
||||
expect(WALK_IMPORT_REFUSALS).toEqual([
|
||||
'not_google_maps',
|
||||
'not_a_route',
|
||||
'not_walking',
|
||||
'mode_unknown',
|
||||
'short_link_refused',
|
||||
'short_link_failed',
|
||||
'too_few_stops',
|
||||
'too_many_stops',
|
||||
]);
|
||||
expect(walkImportRefusalSchema.safeParse('driving').success).toBe(false);
|
||||
expect(walkImportRefusalResponseSchema.parse({ error: 'Not on foot.', reason: 'not_walking' })).toEqual({
|
||||
error: 'Not on foot.',
|
||||
reason: 'not_walking',
|
||||
});
|
||||
expect(walkImportRefusalResponseSchema.safeParse({ error: 'Not on foot.' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { stationKeySchema } from './tours.schema';
|
||||
import { stationKeySchema, TOUR_STATION_NAME_MAX } from './tours.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -30,8 +30,13 @@ export type TourVisitUpdateRequest = z.infer<typeof tourVisitUpdateRequestSchema
|
||||
* CW-06, CW-07: a Google Maps walking route shared as a link, read into the
|
||||
* stations of a city walk. Only links Google marks as on foot are taken; the
|
||||
* way between the stations is TREK's own, planned afresh.
|
||||
*
|
||||
* The link is long on purpose: a route of ten stops with their names and the
|
||||
* data Google adds runs past two thousand characters.
|
||||
*/
|
||||
export const WALK_IMPORT_URL_MAX = 2048;
|
||||
export const WALK_IMPORT_URL_MAX = 8192;
|
||||
/** The most stops one link may bring; Google Maps itself takes ten. */
|
||||
export const WALK_IMPORT_STATIONS_MAX = 30;
|
||||
export const walkImportPreviewRequestSchema = z
|
||||
.object({ url: z.string().trim().min(1).max(WALK_IMPORT_URL_MAX) })
|
||||
.strict();
|
||||
@@ -39,15 +44,32 @@ export type WalkImportPreviewRequest = z.infer<typeof walkImportPreviewRequestSc
|
||||
|
||||
/** A stop as the link names it; a stop without coordinates in the link is not guessed at. */
|
||||
export const walkImportStationSchema = z.object({
|
||||
name: z.string().nullable(),
|
||||
name: z.string().max(TOUR_STATION_NAME_MAX).nullable(),
|
||||
lat: z.number().min(-90).max(90).nullable(),
|
||||
lng: z.number().min(-180).max(180).nullable(),
|
||||
});
|
||||
export type WalkImportStation = z.infer<typeof walkImportStationSchema>;
|
||||
|
||||
export const walkImportPreviewResponseSchema = z.object({
|
||||
stations: z.array(walkImportStationSchema),
|
||||
stations: z.array(walkImportStationSchema).min(2).max(WALK_IMPORT_STATIONS_MAX),
|
||||
/** The link as it was read, after its short form was followed. */
|
||||
resolved_url: z.string(),
|
||||
resolved_url: z.string().url(),
|
||||
});
|
||||
export type WalkImportPreviewResponse = z.infer<typeof walkImportPreviewResponseSchema>;
|
||||
|
||||
/** Why a link was not read, for the dialog to say in the reader's language. */
|
||||
export const WALK_IMPORT_REFUSALS = [
|
||||
'not_google_maps',
|
||||
'not_a_route',
|
||||
'not_walking',
|
||||
'mode_unknown',
|
||||
'short_link_refused',
|
||||
'short_link_failed',
|
||||
'too_few_stops',
|
||||
'too_many_stops',
|
||||
] as const;
|
||||
export const walkImportRefusalSchema = z.enum(WALK_IMPORT_REFUSALS);
|
||||
export type WalkImportRefusal = z.infer<typeof walkImportRefusalSchema>;
|
||||
|
||||
/** The body of a refusal: the English text for API and MCP clients, and its reason. */
|
||||
export const walkImportRefusalResponseSchema = z.object({ error: z.string(), reason: walkImportRefusalSchema });
|
||||
|
||||
@@ -244,6 +244,7 @@ Read only: the saved tours of a trip, as the Tours list and the tour detail show
|
||||
| `search_tour_pois` | Places along the saved line of a tour within a corridor (viewpoints, peaks, shelters, drinking water, food, toilets, picnic, campsites, sights, slipways, boat rental, bike repair, and for a city walk museums and galleries, parks and gardens, coffee shops, places to eat and markets): source (TREK places index or OpenStreetMap), kind, address, website, phone, opening hours as the source has them (a hint, never a promise), the distance straight across to the line (not a detour) and how far along the line; up to 60, the nearest to the line when more match, listed from the start. A place OpenStreetMap has no name for comes with an empty name. `unavailable` names the categories no source could be searched for right now, so their places are missing rather than absent. Says when stretches could not be searched, only the first part of a long line was, or more places matched. Nothing is added | `trips:read` |
|
||||
| `list_tour_library` | The saved tours of every trip the user can open, to choose one to take over into another trip: name, type, distance, ascent, descent, planned times and the trip each is in. Narrowed by a part of the name, a type or a trip; the trip being planned can be left out. Up to 100 tours, `truncated` says there were more | `trips:read` |
|
||||
| `take_over_tour` | Copies a saved tour of another trip into a trip as an independent tour: its line, measures, settings and control points, without its days, its links to places of the other trip or the obstacle decisions taken there. The copy remembers which trip and tour it came from; the source trip is not changed. Needs `place_edit` on the trip it writes into | `trips:write` |
|
||||
| `preview_google_maps_walk` | Reads the stops of a Google Maps walking route shared as a link (a `maps.app.goo.gl` short link or a `google.com/maps/dir` link), in order, for a city walk: each stop's name and position, or no position when the link gives none (nothing is looked up or guessed), and the long link that was read. Only a route Google Maps marks as on foot is read; a car, bike or transit route, or a link that does not say, is refused. A short link is followed only to Google Maps itself. Nothing is saved, and the line drawn in Google Maps is not taken over: TREK plans the way on foot between the stops again. Needs `place_edit` on the trip | `trips:read` |
|
||||
| `plan_paddle_route` | Plans the legs of a paddle tour on the water network TREK builds from the paddle regions an admin added: each leg (`from`, `to`) along rivers and canals and across lakes, carried round weirs, dams and waterfalls, through or round locks (`lock_policy`), with `boat` and `lake_style` (`direct` or `shore`). Answers per leg its length, how far its ends were moved onto the water and its stretches (water, lake, whitewater, boat pass, portage with its source, lock, gap); a leg that cannot be planned says why (`not_on_water`, `no_region`, `cross_region`, `no_water_path`, `busy`, `timeout`). Nothing is saved; a planning aid, never a safety or legal clearance | `trips:read` |
|
||||
|
||||
### Road trip _(Road trip addon required)_
|
||||
|
||||
Reference in New Issue
Block a user