mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
feat(tours): download a saved tour as a GPX file (PR1-06A)
A saved tour can be handed over as a GPX file on purpose: its whole saved line as one track with the heights it has and none it lacks, its name, description and link, and the activity in <type>. The file is named after the tour. A tour without a line of two points is refused with 422 rather than written as a file that imports as nothing; the export changes nothing. GET /api/trips/:tripId/tours/:placeId/export.gpx, readable by anyone who sees the trip, and the MCP tool export_tour_gpx with the same rules. The tour detail gets a "GPX" action beside the navigation, the planner head a download button that waits while the tour has unsaved changes. The tours rail head now uses the same plain icon buttons as the editor.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* The file name a download's Content-Disposition gives: the UTF-8 `filename*`
|
||||
* first, since it keeps the tour's own script, the plain `filename` after.
|
||||
*/
|
||||
export function attachmentFilename(header: unknown): string | null {
|
||||
if (typeof header !== 'string') return null;
|
||||
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)?.[1];
|
||||
if (encoded) {
|
||||
try {
|
||||
return decodeURIComponent(encoded.trim());
|
||||
} catch {
|
||||
// A malformed escape falls through to the plain name.
|
||||
}
|
||||
}
|
||||
return /filename="([^"]+)"/i.exec(header)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** PR1-06A: one saved tour as the GPX file the server writes. */
|
||||
export const tourGpxExportApi = {
|
||||
download: async (
|
||||
tripId: number | string,
|
||||
placeId: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ blob: Blob; filename: string | null }> => {
|
||||
const response = await apiClient.get<Blob>(`/trips/${tripId}/tours/${placeId}/export.gpx`, {
|
||||
responseType: 'blob',
|
||||
signal,
|
||||
});
|
||||
return { blob: response.data, filename: attachmentFilename(response.headers['content-disposition']) };
|
||||
},
|
||||
};
|
||||
@@ -15,8 +15,11 @@ import { fs } from '../shared/DialogShell'
|
||||
export const PANEL_BAR = 'flex min-h-[52px] flex-none items-center gap-1.5 border-b border-edge-faint px-3 py-2'
|
||||
|
||||
/** A 32px icon control on a panel bar. Raised like an active filter tab while `active`. */
|
||||
export function BarButton({ label, onClick, active = false, disabled = false, children, className = '', ariaPressed, ariaExpanded, ariaHaspopup, placement = 'bottom' }: {
|
||||
export function BarButton({ label, tooltip, onClick, active = false, disabled = false, children, className = '', ariaPressed, ariaExpanded, ariaHaspopup, placement = 'bottom', testId }: {
|
||||
label: string
|
||||
/** A longer explanation for the tooltip; the label still names the button. */
|
||||
tooltip?: string
|
||||
testId?: string
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
@@ -28,8 +31,8 @@ export function BarButton({ label, onClick, active = false, disabled = false, ch
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right'
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={label} placement={placement}>
|
||||
<button type="button" onClick={onClick} disabled={disabled} aria-label={label}
|
||||
<Tooltip label={tooltip ?? label} placement={placement}>
|
||||
<button type="button" onClick={onClick} disabled={disabled} aria-label={label} data-testid={testId}
|
||||
aria-pressed={ariaPressed} aria-expanded={ariaExpanded} aria-haspopup={ariaHaspopup}
|
||||
className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] transition-colors disabled:cursor-default disabled:opacity-40 ${active ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted enabled:hover:bg-surface-card enabled:hover:text-content'} ${className}`}>
|
||||
{children}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { Tooltip } from '../shared/Tooltip';
|
||||
import TrackColorPicker from '../shared/TrackColorPicker';
|
||||
import { markdownLinkComponents } from '../shared/markdownLink';
|
||||
import { TourDayRowAvatar } from './TourDayRowFacts';
|
||||
import { TourGpxExportButton } from './TourGpxExportButton';
|
||||
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
|
||||
import { PaddleWindSection } from './paddle/PaddleWindCard';
|
||||
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
|
||||
@@ -588,6 +589,10 @@ export default function TourDetailDialog({
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* A tour written here offline has no server copy to export until it syncs. */}
|
||||
{analysis && place.id > 0 && (
|
||||
<TourGpxExportButton target={{ tripId: place.trip_id, placeId: place.id, name: place.name }} />
|
||||
)}
|
||||
{canEdit && onSaveAsNew && <SaveAsNewButton onSave={() => onSaveAsNew(tour)} />}
|
||||
<FooterSpacer />
|
||||
{canEdit && onDelete && (
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { BarButton } from '../Planner/planParts';
|
||||
import { DialogButton } from '../shared/DialogShell';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { useTourGpxExport, type TourGpxExportTarget } from './useTourGpxExport';
|
||||
|
||||
/**
|
||||
* PR1-06A in a footer: the saved tour as a GPX file, its own action beside
|
||||
* the navigation, which only leads to the start. "GPX" says the format, the
|
||||
* tooltip what the file holds.
|
||||
*/
|
||||
export function TourGpxExportButton({ target }: { target: TourGpxExportTarget }) {
|
||||
const { t } = useTranslation();
|
||||
const { exportGpx, exporting } = useTourGpxExport();
|
||||
return (
|
||||
<Tooltip label={t('tours.gpxExport.hint')}>
|
||||
<span className="inline-flex">
|
||||
<DialogButton
|
||||
onClick={() => void exportGpx(target)}
|
||||
disabled={exporting}
|
||||
aria-label={t('tours.gpxExport.action')}
|
||||
data-testid="tour-gpx-export"
|
||||
icon={exporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} strokeWidth={2} />}
|
||||
>
|
||||
<span className="max-sm:hidden">GPX</span>
|
||||
</DialogButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same export in the planner's head. While the tour has unsaved changes
|
||||
* the file would not hold them, so the button waits for the save and says so.
|
||||
*/
|
||||
export function TourGpxExportBarButton({ target, unsaved }: { target: TourGpxExportTarget; unsaved: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { exportGpx, exporting } = useTourGpxExport();
|
||||
return (
|
||||
<BarButton
|
||||
label={t(unsaved ? 'tours.gpxExport.saveFirst' : 'tours.gpxExport.action')}
|
||||
onClick={() => void exportGpx(target)}
|
||||
disabled={unsaved || exporting}
|
||||
>
|
||||
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} strokeWidth={2} />}
|
||||
</BarButton>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import type { RecordedTrack } from '../useRecordedTrack'
|
||||
import type { TourGpxImport } from '../useTourGpxImport'
|
||||
import { type AssignTourToDay, useTourDayAssignment } from '../useTourDayAssignment'
|
||||
import WandererMark from '../wanderer/WandererMark'
|
||||
import { TourGpxExportBarButton } from '../TourGpxExportButton'
|
||||
|
||||
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
|
||||
dirty: 'tours.planner.status.dirty',
|
||||
@@ -291,6 +292,9 @@ function GpxViewRail({
|
||||
sub={tour.name}
|
||||
aside={<SoftPill>{hikeSourceBadgeLabel(tour, t)}</SoftPill>}
|
||||
/>
|
||||
{tour.place_id > 0 && (
|
||||
<TourGpxExportBarButton target={{ tripId: planner.tripId, placeId: tour.place_id, name: tour.name }} unsaved={false} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 py-3">
|
||||
{conversion && <TourConversionSection conversion={conversion} />}
|
||||
@@ -399,6 +403,12 @@ export function TourPlannerRail({ planner,
|
||||
<ArrowLeft size={16} strokeWidth={2} />
|
||||
</BarButton>
|
||||
<RailTitle title={t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')} aside={<RailStatusBadges planner={planner} />} />
|
||||
{planner.mode.type === 'edit-saved' && planner.mode.placeId > 0 && (
|
||||
<TourGpxExportBarButton
|
||||
target={{ tripId: planner.tripId, placeId: planner.mode.placeId, name: planner.name }}
|
||||
unsaved={planner.hasUnsavedChanges}
|
||||
/>
|
||||
)}
|
||||
<BarButton label={t('tours.planner.undo')} onClick={() => { if (canEdit && !isSaving) planner.undo() }} disabled={locked || !planner.canUndo}>
|
||||
<Undo2 size={15} strokeWidth={2} />
|
||||
</BarButton>
|
||||
@@ -557,26 +567,20 @@ function NewTourConfirmDialog({ planner, canEdit }: { planner: TourPlannerContro
|
||||
)
|
||||
}
|
||||
|
||||
/** The round GPX import button in the tours rail's head; the file drop on the workspace does the same. */
|
||||
/** The GPX import in the tours rail's head, an icon like the editor's; the file drop on the workspace does the same. */
|
||||
function RailImportButton({ gpxImport, disabled }: { gpxImport: TourGpxImport; disabled: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Tooltip label={t('tours.import.hint')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('tours.import.button')}
|
||||
data-testid="tour-rail-import-gpx"
|
||||
disabled={gpxImport.importing || disabled}
|
||||
onClick={gpxImport.pick}
|
||||
className="mt-1 grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-edge-faint transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:bg-accent enabled:hover:text-accent-text enabled:hover:ring-transparent disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
{gpxImport.importing ? (
|
||||
<Loader2 size={13} strokeWidth={2.4} className="animate-spin" />
|
||||
) : (
|
||||
<FileDown size={13} strokeWidth={2.4} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<BarButton
|
||||
label={t('tours.import.button')}
|
||||
tooltip={t('tours.import.hint')}
|
||||
testId="tour-rail-import-gpx"
|
||||
disabled={gpxImport.importing || disabled}
|
||||
onClick={gpxImport.pick}
|
||||
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
{gpxImport.importing ? <Loader2 size={15} strokeWidth={2} className="animate-spin" /> : <FileDown size={15} strokeWidth={2} />}
|
||||
</BarButton>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -673,13 +677,11 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
|
||||
<RailTitle title={t('tours.planner.tripTours')} />
|
||||
{canEdit && gpxImport && <RailImportButton gpxImport={gpxImport} disabled={isSaving} />}
|
||||
{canEdit && planner.mode.type !== 'neutral' && (
|
||||
<Tooltip label={t('tours.planner.newTour')}>
|
||||
<button type="button" aria-label={t('tours.planner.newTour')} data-testid="tour-rail-new-tour"
|
||||
disabled={isSaving} onClick={() => { if (!isSaving) planner.startNewTour() }}
|
||||
className="mt-1 grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-accent text-accent-text shadow-sm transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-40">
|
||||
<Plus size={14} strokeWidth={2.4} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<BarButton label={t('tours.planner.newTour')} testId="tour-rail-new-tour" disabled={isSaving}
|
||||
onClick={() => { if (!isSaving) planner.startNewTour() }}
|
||||
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
|
||||
<Plus size={16} strokeWidth={2} />
|
||||
</BarButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -927,7 +927,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
description, savedDurationSeconds, editingPlaceId, enrichedGeometry, invalidateDetailRequest, name, onSaved, plannedDurationMinutes, storageKey, tripId, typeFields, waypoints, website])
|
||||
|
||||
return {
|
||||
canEdit, canAssign,
|
||||
tripId, canEdit, canAssign,
|
||||
name, setName, maxHikingDifficulty, setMaxHikingDifficulty,
|
||||
tourType, setTourType, isPaddle, paddle, paddleSpeedInvalid, paddleIncomplete,
|
||||
description, setDescription, website, setWebsite, websiteInvalid,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { act, fireEvent, render, renderHook, screen } from '@testing-library/react';
|
||||
import { AxiosError, AxiosHeaders } from 'axios';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { attachmentFilename } from '../../api/tourGpxExport';
|
||||
import { TourGpxExportBarButton, TourGpxExportButton } from './TourGpxExportButton';
|
||||
import { useTourGpxExport } from './useTourGpxExport';
|
||||
|
||||
const repo = vi.hoisted(() => ({ exportGpx: vi.fn() }));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||
const download = vi.hoisted(() => ({ downloadBlob: vi.fn() }));
|
||||
vi.mock('../../repo/tourRepo', () => ({ tourRepo: repo }));
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||
vi.mock('../../utils/fileDownload', () => download);
|
||||
|
||||
const target = { tripId: 1, placeId: 7, name: 'Kramer' };
|
||||
const blob = new Blob(['<gpx/>'], { type: 'application/gpx+xml' });
|
||||
|
||||
function refused(status: number) {
|
||||
const headers = new AxiosHeaders();
|
||||
return new AxiosError('refused', 'ERR', undefined, undefined, {
|
||||
status,
|
||||
statusText: '',
|
||||
headers,
|
||||
config: { headers },
|
||||
data: {},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo.exportGpx.mockResolvedValue({ offline: false, blob, filename: 'Kramer-über.gpx' });
|
||||
});
|
||||
|
||||
describe('attachmentFilename (PR1-06A)', () => {
|
||||
it('prefers the UTF-8 name, falls back to the plain one, and reads nothing from nothing', () => {
|
||||
expect(attachmentFilename(`attachment; filename="Kramer-_ber.gpx"; filename*=UTF-8''Kramer-%C3%BCber.gpx`)).toBe(
|
||||
'Kramer-über.gpx'
|
||||
);
|
||||
expect(attachmentFilename('attachment; filename="plain.gpx"')).toBe('plain.gpx');
|
||||
expect(attachmentFilename(`attachment; filename="safe.gpx"; filename*=UTF-8''%E0%A4%A`)).toBe('safe.gpx');
|
||||
expect(attachmentFilename('attachment')).toBeNull();
|
||||
expect(attachmentFilename(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTourGpxExport (PR1-06A)', () => {
|
||||
it('hands the server file to the browser under its own name', async () => {
|
||||
const { result } = renderHook(() => useTourGpxExport());
|
||||
await act(() => result.current.exportGpx(target));
|
||||
expect(repo.exportGpx).toHaveBeenCalledWith(1, 7, expect.any(AbortSignal));
|
||||
expect(download.downloadBlob).toHaveBeenCalledWith(blob, 'Kramer-über.gpx');
|
||||
expect(result.current.exporting).toBe(false);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names the file after the tour when the server gave no name', async () => {
|
||||
repo.exportGpx.mockResolvedValue({ offline: false, blob, filename: null });
|
||||
const { result } = renderHook(() => useTourGpxExport());
|
||||
await act(() => result.current.exportGpx({ ...target, name: ' ' }));
|
||||
expect(download.downloadBlob).toHaveBeenCalledWith(blob, 'tour.gpx');
|
||||
});
|
||||
|
||||
it('says why nothing came: offline, a tour without a route, or a failure', async () => {
|
||||
const { result } = renderHook(() => useTourGpxExport());
|
||||
repo.exportGpx.mockResolvedValueOnce({ offline: true });
|
||||
await act(() => result.current.exportGpx(target));
|
||||
expect(toast.info).toHaveBeenCalledWith('tours.gpxExport.offline');
|
||||
|
||||
repo.exportGpx.mockRejectedValueOnce(refused(422));
|
||||
await act(() => result.current.exportGpx(target));
|
||||
expect(toast.error).toHaveBeenLastCalledWith('tours.gpxExport.noRoute');
|
||||
|
||||
repo.exportGpx.mockRejectedValueOnce(new Error('network'));
|
||||
await act(() => result.current.exportGpx(target));
|
||||
expect(toast.error).toHaveBeenLastCalledWith('tours.gpxExport.failed');
|
||||
expect(download.downloadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs one export at a time, and a caller gone mid-download cancels it quietly', async () => {
|
||||
let finish: (value: unknown) => void = () => undefined;
|
||||
repo.exportGpx.mockImplementation(() => new Promise((resolve) => (finish = resolve)));
|
||||
const { result, unmount } = renderHook(() => useTourGpxExport());
|
||||
act(() => {
|
||||
void result.current.exportGpx(target);
|
||||
});
|
||||
expect(result.current.exportingId).toBe(7);
|
||||
await act(() => result.current.exportGpx(target));
|
||||
expect(repo.exportGpx).toHaveBeenCalledTimes(1);
|
||||
|
||||
const signal = repo.exportGpx.mock.calls[0][2] as AbortSignal;
|
||||
unmount();
|
||||
expect(signal.aborted).toBe(true);
|
||||
await act(async () => finish({ offline: false, blob, filename: 'late.gpx' }));
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourGpxExportButton (PR1-06A)', () => {
|
||||
it('exports from the footer, apart from the navigation', async () => {
|
||||
render(<TourGpxExportButton target={target} />);
|
||||
const button = screen.getByTestId('tour-gpx-export');
|
||||
expect(button).toHaveAccessibleName('tours.gpxExport.action');
|
||||
await act(async () => fireEvent.click(button));
|
||||
expect(repo.exportGpx).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('waits in the planner head while the tour has unsaved changes, and says so', () => {
|
||||
const { rerender } = render(<TourGpxExportBarButton target={target} unsaved />);
|
||||
expect(screen.getByRole('button', { name: 'tours.gpxExport.saveFirst' })).toBeDisabled();
|
||||
rerender(<TourGpxExportBarButton target={target} unsaved={false} />);
|
||||
const button = screen.getByRole('button', { name: 'tours.gpxExport.action' });
|
||||
expect(button).toBeEnabled();
|
||||
fireEvent.click(button);
|
||||
expect(repo.exportGpx).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { isAxiosError } from 'axios';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { tourRepo } from '../../repo/tourRepo';
|
||||
import { downloadBlob } from '../../utils/fileDownload';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/** What a tour's GPX export needs to know about the tour. */
|
||||
export interface TourGpxExportTarget {
|
||||
tripId: number | string;
|
||||
placeId: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PR1-06A: a saved tour handed over as a GPX file, started on purpose. The
|
||||
* file is the server's (the saved line, as saved), so an unsaved edit in the
|
||||
* planner is not in it. One export at a time per caller; a dialog closed
|
||||
* mid-download cancels it. Nothing about the tour changes either way.
|
||||
*/
|
||||
export function useTourGpxExport() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [exportingId, setExportingId] = useState<number | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
const exportGpx = useCallback(
|
||||
async ({ tripId, placeId, name }: TourGpxExportTarget) => {
|
||||
if (abortRef.current) return;
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setExportingId(placeId);
|
||||
try {
|
||||
const result = await tourRepo.exportGpx(tripId, placeId, controller.signal);
|
||||
if (!('blob' in result)) {
|
||||
toast.info(t('tours.gpxExport.offline'));
|
||||
return;
|
||||
}
|
||||
downloadBlob(result.blob, result.filename ?? `${name.trim() || 'tour'}.gpx`);
|
||||
} catch (error: unknown) {
|
||||
if (controller.signal.aborted) return;
|
||||
const noRoute = isAxiosError(error) && error.response?.status === 422;
|
||||
toast.error(t(noRoute ? 'tours.gpxExport.noRoute' : 'tours.gpxExport.failed'));
|
||||
} finally {
|
||||
if (abortRef.current === controller) abortRef.current = null;
|
||||
if (!controller.signal.aborted) setExportingId(null);
|
||||
}
|
||||
},
|
||||
[t, toast]
|
||||
);
|
||||
|
||||
return { exportGpx, exportingId, exporting: exportingId !== null };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { tourGpxApi } from '../api/tourGpx'
|
||||
import { recordedTrackApi } from '../api/recordedTrack'
|
||||
import { tourPaddlePlanApi } from '../api/tourPaddlePlan'
|
||||
import { tourCopiesApi } from '../api/tourCopies'
|
||||
import { tourGpxExportApi } from '../api/tourGpxExport'
|
||||
import { tourObstacleReviewsApi } from '../api/tourObstacleReviews'
|
||||
import { cacheTourAnswer } from '../db/cacheTour'
|
||||
import { offlineDb, replaceTripTours, type CachedTour } from '../db/offlineDb'
|
||||
@@ -258,6 +259,20 @@ export const tourRepo = {
|
||||
* P2C-07, online only like the restore: the copy is made from the server's
|
||||
* stored line and control points, so it needs the server to answer.
|
||||
*/
|
||||
/**
|
||||
* PR1-06A: the saved tour as the GPX file the server writes. Online only on
|
||||
* purpose, since the file is the server's; offline it answers `offline`
|
||||
* rather than a request that cannot reach anyone.
|
||||
*/
|
||||
async exportGpx(
|
||||
tripId: number | string,
|
||||
placeId: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ offline: true } | { offline: false; blob: Blob; filename: string | null }> {
|
||||
if (isEffectivelyOffline()) return { offline: true }
|
||||
return { offline: false, ...(await tourGpxExportApi.download(tripId, placeId, signal)) }
|
||||
},
|
||||
|
||||
async copy(tripId: number | string, placeId: number, name: string): Promise<TourCreateResponse> {
|
||||
const result = await tourCopiesApi.copy(tripId, placeId, { name })
|
||||
await cacheTourAnswer(Number(tripId), result)
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
import { CollectionGpxProvider, type ExportedCollectionFile } from './providers/collection-gpx.provider';
|
||||
import { GoogleDirectionsProvider } from './providers/google-directions.provider';
|
||||
import { GoogleListProvider } from './providers/google-list.provider';
|
||||
import { GpxProvider, type GpxExportInput, type GpxExportOptions } from './providers/gpx.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';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
@@ -21,7 +21,13 @@ 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';
|
||||
export type { GpxExportDay, GpxExportInput, GpxExportOptions, GpxExportPlace } from './providers/gpx.provider';
|
||||
export type {
|
||||
GpxExportDay,
|
||||
GpxExportInput,
|
||||
GpxExportOptions,
|
||||
GpxExportPlace,
|
||||
GpxTourExport,
|
||||
} from './providers/gpx.provider';
|
||||
export type { ExportedCollectionFile } from './providers/collection-gpx.provider';
|
||||
|
||||
/** Places a name, or says nobody can. */
|
||||
@@ -64,6 +70,15 @@ export class PlaceImportService {
|
||||
return this.gpx.filename(tripTitle);
|
||||
}
|
||||
|
||||
/** One tour as a GPX document (PR1-06A), or null when it has no line to write. */
|
||||
writeTourGpx(tour: GpxTourExport): string | null {
|
||||
return this.gpx.writeTour(tour);
|
||||
}
|
||||
|
||||
tourGpxFilename(tourName: string): string {
|
||||
return this.gpx.tourFilename(tourName);
|
||||
}
|
||||
|
||||
/** A list file as GPX (#2301). */
|
||||
writeCollectionGpx(file: ExportedCollectionFile): CollectionGpxExport {
|
||||
return this.collectionGpx.write(file);
|
||||
|
||||
@@ -145,11 +145,58 @@ export function buildGpx(input: GpxExportInput, opts: GpxExportOptions = {}): st
|
||||
return writeGpx({ metadata: { name: input.tripTitle }, wpt, rte, trk });
|
||||
}
|
||||
|
||||
/** One tour as the exporter needs it (PR1-06A). */
|
||||
export interface GpxTourExport {
|
||||
name: string;
|
||||
description: string | null;
|
||||
/** The tour's own information link, written as the track's <link>. */
|
||||
website: string | null;
|
||||
/** The activity as GPX names it in <type>, e.g. `hiking`. */
|
||||
activity: string;
|
||||
/** JSON `[[lat, lng]]` or `[[lat, lng, ele]]`, the tour's saved line. */
|
||||
route_geometry: string | null;
|
||||
/** Named stops along the tour, written as waypoints in their order. */
|
||||
stops: Array<{ name: string; lat: number; lng: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* One tour as a GPX document: its whole saved line as one track, with the
|
||||
* heights it has and none it lacks, and its named stops as waypoints. Null when
|
||||
* the tour has no line of at least two points, so the caller refuses instead
|
||||
* of handing over a file a device would read as nothing.
|
||||
*/
|
||||
export function buildTourGpx(tour: GpxTourExport): string | null {
|
||||
const geometry = parseGeometry(tour.route_geometry);
|
||||
if (geometry.length < 2) return null;
|
||||
const desc = tour.description?.trim() || undefined;
|
||||
const link = tour.website ? { '@_href': tour.website } : undefined;
|
||||
return writeGpx({
|
||||
metadata: { name: tour.name, ...(desc ? { desc } : {}), ...(link ? { link } : {}) },
|
||||
wpt: tour.stops.map((stop) => ({ '@_lat': coord(stop.lat), '@_lon': coord(stop.lng), name: stop.name })),
|
||||
trk: [
|
||||
{
|
||||
name: tour.name,
|
||||
desc,
|
||||
link,
|
||||
type: tour.activity,
|
||||
trkseg: {
|
||||
trkpt: geometry.map((p) => ({
|
||||
'@_lat': coord(p.lat),
|
||||
'@_lon': coord(p.lng),
|
||||
...(p.ele != null ? { ele: coord(p.ele) } : {}),
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Filenames land on the receiving filesystem, so its reserved characters are
|
||||
* folded away rather than escaped. Header safety is contentDisposition()'s
|
||||
* job since #2165, which is why the title's own script survives here —
|
||||
* 沖縄 stays 沖縄 instead of being mangled into underscores. */
|
||||
export function gpxFilename(tripTitle: string): string {
|
||||
* 沖縄 stays 沖縄 instead of being mangled into underscores. `fallback`
|
||||
* names a file whose title folds away to nothing. */
|
||||
export function gpxFilename(tripTitle: string, fallback = 'trip'): string {
|
||||
const base = tripTitle
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\x00-\x1f\x7f"\\/:*?<>|]+/g, ' ')
|
||||
@@ -158,7 +205,7 @@ export function gpxFilename(tripTitle: string): string {
|
||||
.replaceAll(' ', '-');
|
||||
// Codepoints, not UTF-16 units: slice() would cut an emoji in half and the
|
||||
// lone surrogate is exactly what URI-encoding chokes on.
|
||||
return `${[...base].slice(0, 60).join('') || 'trip'}.gpx`;
|
||||
return `${[...base].slice(0, 60).join('') || fallback}.gpx`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,4 +321,12 @@ export class GpxProvider {
|
||||
filename(tripTitle: string): string {
|
||||
return gpxFilename(tripTitle);
|
||||
}
|
||||
|
||||
writeTour(tour: GpxTourExport): string | null {
|
||||
return buildTourGpx(tour);
|
||||
}
|
||||
|
||||
tourFilename(tourName: string): string {
|
||||
return gpxFilename(tourName, 'tour');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import type { ToursRepository } from '../../db/repositories/Tours.repository';
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { toRowId } from '../common/row-id';
|
||||
import { PlaceImportService } from '../place-import/place-import.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { PaddleBoatType } from '@trek/shared';
|
||||
|
||||
/** What GPX calls the activity in a track's <type>; a paddle tour says what it is paddled in. */
|
||||
const PADDLE_ACTIVITY: Record<PaddleBoatType, string> = {
|
||||
kayak: 'kayaking',
|
||||
canoe: 'canoeing',
|
||||
sup: 'stand_up_paddling',
|
||||
other: 'paddling',
|
||||
};
|
||||
|
||||
export function gpxActivityOf(type: string, boat: string | null): string {
|
||||
if (type === 'kayak') return PADDLE_ACTIVITY[(boat ?? 'other') as PaddleBoatType] ?? PADDLE_ACTIVITY.other;
|
||||
return type === 'hike' ? 'hiking' : type;
|
||||
}
|
||||
|
||||
/**
|
||||
* PR1-06A: one saved tour as a GPX file, its whole saved line with the heights
|
||||
* it has. A read: nothing about the tour, its days or where it came from
|
||||
* changes, and a tour without a line of its own is refused rather than written
|
||||
* as a file that imports as nothing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourGpxExportService {
|
||||
constructor(
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
private readonly placeImport: PlaceImportService,
|
||||
) {}
|
||||
|
||||
async export(tripId: string, placeId: string): Promise<{ gpx: string; filename: string }> {
|
||||
const tid = toRowId(tripId);
|
||||
const pid = toRowId(placeId);
|
||||
const tour = tid !== null && pid !== null ? await this.toursRepo.findInTrip(tid, pid) : undefined;
|
||||
const place = tour && tid !== null ? await this.placesRepo.findInTrip(tour.place_id, tid) : undefined;
|
||||
if (!tour || !place) throw new NotFoundException('Tour not found');
|
||||
|
||||
const gpx = this.placeImport.writeTourGpx({
|
||||
name: tour.name,
|
||||
description: tour.description ?? null,
|
||||
website: tour.website ?? null,
|
||||
activity: gpxActivityOf(tour.tour_type, tour.boat_type),
|
||||
route_geometry: place.route_geometry ?? null,
|
||||
stops: [],
|
||||
});
|
||||
if (!gpx) {
|
||||
throw new DomainError(422, 'This tour has no route to export', { code: 'tour_without_route' });
|
||||
}
|
||||
return { gpx, filename: this.placeImport.tourGpxFilename(tour.name) };
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,21 @@ import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { contentDisposition } from '../common/content-disposition';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { TourCopyDto, TourCreateDto } from './dto/tour-create.dto';
|
||||
import { TourObstacleReviewsDto } from './dto/tour-obstacle-reviews.dto';
|
||||
import { TourPaddlePlanDto } from './dto/tour-paddle-plan.dto';
|
||||
import { TourGpxExportService } from './tour-gpx-export.service';
|
||||
import { TourObstacleReviewsService } from './tour-obstacle-reviews.service';
|
||||
import { TourPaddlePlanService } from './tour-paddle-plan.service';
|
||||
import { ToursService } from './tours.service';
|
||||
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, Res, UseGuards } from '@nestjs/common';
|
||||
import { tourCreateResponseSchema, tourDetailResponseSchema } from '@trek/shared';
|
||||
|
||||
import type { Response } from 'express';
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/tours: the Tours facet, read and route edits.
|
||||
* TripAccessGuard resolves
|
||||
@@ -30,6 +34,7 @@ export class ToursController {
|
||||
private readonly tours: ToursService,
|
||||
private readonly paddlePlans: TourPaddlePlanService,
|
||||
private readonly obstacleReviews: TourObstacleReviewsService,
|
||||
private readonly gpxExport: TourGpxExportService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -37,6 +42,19 @@ export class ToursController {
|
||||
return { tours: await this.tours.listTours(tripId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* PR1-06A: the saved tour as a GPX file, its whole line with the heights it
|
||||
* has. A read, so seeing the trip is enough; 422 when the tour has no line.
|
||||
* response-contract-exempt: a file download written through @Res(), not a JSON body.
|
||||
*/
|
||||
@Get(':placeId/export.gpx')
|
||||
async exportGpx(@Param('tripId') tripId: string, @Param('placeId') placeId: string, @Res() res: Response) {
|
||||
const { gpx, filename } = await this.gpxExport.export(tripId, placeId);
|
||||
res.setHeader('Content-Type', 'application/gpx+xml; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', contentDisposition(filename, 'attachment'));
|
||||
res.send(gpx);
|
||||
}
|
||||
|
||||
@Get(':placeId')
|
||||
async detail(@Param('tripId') tripId: string, @Param('placeId') placeId: string) {
|
||||
return await this.tours.getTour(tripId, placeId);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, errorResult, ok, type M
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { TourGpxExportService } from './tour-gpx-export.service';
|
||||
import { ToursService } from './tours.service';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { idSchema, type TourListItem } from '@trek/shared';
|
||||
@@ -30,6 +31,7 @@ export class ToursMcp {
|
||||
constructor(
|
||||
private readonly tours: ToursService,
|
||||
private readonly access: TripAccessService,
|
||||
private readonly gpxExport: TourGpxExportService,
|
||||
// Read by the addon gate of every tool here.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
@@ -95,4 +97,24 @@ export class ToursMcp {
|
||||
const onDay = tours.filter((tour) => (days.get(tour.place_id) ?? []).some((day) => day.day_id === dayId));
|
||||
return ok({ tours: onDay.map((tour) => withDays(tour, days)) });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'export_tour_gpx',
|
||||
description:
|
||||
'Export one saved tour of a trip as a GPX 1.1 document: its whole saved line as one track, with the heights it carries and none it lacks. Returns the GPX text and a file name. A tour without a line of at least two points cannot be exported. Nothing about the tour changes.',
|
||||
inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it') },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async exportTourGpx({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
|
||||
// A read, like the REST route: seeing the trip is enough.
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
try {
|
||||
return ok(await this.gpxExport.export(String(tripId), String(placeId)));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof NotFoundException) return errorResult('Tour not found.');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PlaceImportModule } from '../place-import/place-import.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
|
||||
import { WandererModule } from '../wanderer/wanderer.module';
|
||||
import { TourGpxExportService } from './tour-gpx-export.service';
|
||||
import { TourObstacleReviewsService } from './tour-obstacle-reviews.service';
|
||||
import { PaddleCheckCache } from './tour-paddle-check.cache';
|
||||
import { TourPaddleCheckService } from './tour-paddle-check.service';
|
||||
@@ -52,6 +53,7 @@ import { Module } from '@nestjs/common';
|
||||
PaddleCheckCache,
|
||||
TourPaddleCheckService,
|
||||
TourObstacleReviewsService,
|
||||
TourGpxExportService,
|
||||
ToursWandererService,
|
||||
ToursMcp,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Tours e2e for the planner's second round: the GPX export of one tour
|
||||
* (PR1-06A), through the real JwtAuthGuard, AddonGuard and TripAccessGuard
|
||||
* against a migrated and seeded temp SQLite db. The permission check is a
|
||||
* container spy and the broadcast goes to a FakeRealtimeService, as in
|
||||
* tours.e2e.test.ts.
|
||||
*
|
||||
* Fixtures: user 1 owns trips 5 and 6, user 2 is a member of trip 5 whom the
|
||||
* permission spy denies 'place_edit', user 3 has no access to either trip.
|
||||
*/
|
||||
import { db } from '../../src/db/database';
|
||||
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
||||
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 { 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 { makePlace } from '../helpers/factories/places';
|
||||
import { deleteRows, findRow, insertRows } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { makeTour } from '../helpers/factories/tours';
|
||||
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 cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
return buildDbMock(createSnapshotTestDb());
|
||||
});
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: 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 realtime = new FakeRealtimeService();
|
||||
|
||||
let orm: TestOrm;
|
||||
let checkPermission: MockInstance;
|
||||
|
||||
const OWNER = 1;
|
||||
const VIEWER = 2;
|
||||
const OUTSIDER = 3;
|
||||
|
||||
const GEOMETRY = [
|
||||
[47, 11, 600],
|
||||
[47.01, 11.01, 700],
|
||||
[47.02, 11.02, 650],
|
||||
];
|
||||
const tourBody = (overrides: Record<string, unknown> = {}) => ({
|
||||
name: 'Ridge walk',
|
||||
route_geometry: GEOMETRY,
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
||||
],
|
||||
max_hiking_difficulty: 3,
|
||||
duration_seconds: 3600,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Tours planned e2e (real guards + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
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)
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
const createViaApi = async (tripId = 5, body: Record<string, unknown> = tourBody()) => {
|
||||
const res = await request(server).post(`/api/trips/${tripId}/tours`).set('Cookie', sessionCookie(OWNER)).send(body);
|
||||
expect(res.status).toBe(201);
|
||||
return res.body.tour.place_id as number;
|
||||
};
|
||||
|
||||
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: 'Alps' },
|
||||
{ id: 6, user: 1, title: 'Other' },
|
||||
]);
|
||||
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);
|
||||
realtime.broadcastMock.mockClear();
|
||||
await deleteRows(orm, DayAssignments);
|
||||
await deleteRows(orm, Places);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await orm.close();
|
||||
});
|
||||
|
||||
describe('GET :placeId/export.gpx (PR1-06A)', () => {
|
||||
const exportGpx = (userId: number, placeId: number, tripId = 5) =>
|
||||
request(server)
|
||||
.get(`/api/trips/${tripId}/tours/${placeId}/export.gpx`)
|
||||
.set('Cookie', sessionCookie(userId))
|
||||
.buffer(true)
|
||||
.parse((res, done) => {
|
||||
let text = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk: string) => (text += chunk));
|
||||
res.on('end', () => done(null, text));
|
||||
});
|
||||
|
||||
it('writes the whole saved line with its heights as one track, named after the tour', async () => {
|
||||
const placeId = await createViaApi(5, tourBody({ name: 'Grat über Ü', description: 'Früh los.' }));
|
||||
const res = await exportGpx(OWNER, placeId);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('application/gpx+xml; charset=utf-8');
|
||||
expect(res.headers['content-disposition']).toContain("filename*=UTF-8''Grat-%C3%BCber-%C3%9C.gpx");
|
||||
const gpx = res.body as string;
|
||||
expect(gpx).toContain('<name>Grat über Ü</name>');
|
||||
expect(gpx).toContain('<desc>Früh los.</desc>');
|
||||
expect(gpx).toContain('<type>hiking</type>');
|
||||
expect(gpx.match(/<trkpt /g)).toHaveLength(GEOMETRY.length);
|
||||
expect(gpx).toContain('<trkpt lat="47.01" lon="11.01">');
|
||||
expect(gpx).toContain('<ele>700</ele>');
|
||||
});
|
||||
|
||||
it('writes no height a point lacks, and names a paddle tour by its boat', async () => {
|
||||
const place = await makePlace(orm, 5, {
|
||||
name: 'Canal',
|
||||
route_geometry: JSON.stringify([
|
||||
[48, 12],
|
||||
[48.01, 12.01, 300],
|
||||
]),
|
||||
});
|
||||
await makeTour(orm, place.id, {
|
||||
tourTypeRef: 'kayak',
|
||||
boat_type: 'canoe',
|
||||
water_context: 'river',
|
||||
origin: 'gpx',
|
||||
});
|
||||
const res = await exportGpx(OWNER, place.id);
|
||||
expect(res.status).toBe(200);
|
||||
const gpx = res.body as string;
|
||||
expect(gpx).toContain('<type>canoeing</type>');
|
||||
expect(gpx.match(/<ele>/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a member who may only read can export; the export changes nothing', async () => {
|
||||
const placeId = await createViaApi();
|
||||
const before = await findRow(orm, Tours, { place: placeId });
|
||||
realtime.broadcastMock.mockClear();
|
||||
const res = await exportGpx(VIEWER, placeId);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await findRow(orm, Tours, { place: placeId })).toEqual(before);
|
||||
expect(realtime.broadcastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('422 for a tour without a line of two points, 404 for one of another trip or none', async () => {
|
||||
const lonely = await makePlace(orm, 5, { name: 'Dot', route_geometry: JSON.stringify([[47, 11, 1]]) });
|
||||
await makeTour(orm, lonely.id, { origin: 'gpx' });
|
||||
const refused = await request(server)
|
||||
.get(`/api/trips/5/tours/${lonely.id}/export.gpx`)
|
||||
.set('Cookie', sessionCookie(OWNER));
|
||||
expect(refused.status).toBe(422);
|
||||
expect(refused.body).toEqual({ error: 'This tour has no route to export' });
|
||||
|
||||
const elsewhere = await createViaApi(6);
|
||||
const wrongTrip = await request(server)
|
||||
.get(`/api/trips/5/tours/${elsewhere}/export.gpx`)
|
||||
.set('Cookie', sessionCookie(OWNER));
|
||||
expect(wrongTrip.status).toBe(404);
|
||||
expect(wrongTrip.body).toEqual({ error: 'Tour not found' });
|
||||
const plain = await makePlace(orm, 5, { name: 'Cafe' });
|
||||
expect(
|
||||
(await request(server).get(`/api/trips/5/tours/${plain.id}/export.gpx`).set('Cookie', sessionCookie(OWNER)))
|
||||
.status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it('404 Trip not found for an outsider, 404 while the addon is off, 401 without a cookie', async () => {
|
||||
const placeId = await createViaApi();
|
||||
const outsider = await request(server)
|
||||
.get(`/api/trips/5/tours/${placeId}/export.gpx`)
|
||||
.set('Cookie', sessionCookie(OUTSIDER));
|
||||
expect(outsider.status).toBe(404);
|
||||
expect(outsider.body).toEqual({ error: 'Trip not found' });
|
||||
expect((await request(server).get(`/api/trips/5/tours/${placeId}/export.gpx`)).status).toBe(401);
|
||||
await setAddonEnabled(orm, 'tours', false);
|
||||
const off = await request(server)
|
||||
.get(`/api/trips/5/tours/${placeId}/export.gpx`)
|
||||
.set('Cookie', sessionCookie(OWNER));
|
||||
expect(off.status).toBe(404);
|
||||
expect(off.body).toEqual({ error: 'Tours addon is not enabled' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import {
|
||||
buildGpx,
|
||||
buildTourGpx,
|
||||
gpxFilename,
|
||||
type GpxExportPlace,
|
||||
type GpxExportDay,
|
||||
@@ -278,3 +279,70 @@ describe('gpxFilename', () => {
|
||||
expect(gpxFilename('\u{1F5FE}'.repeat(70))).toBe('\u{1F5FE}'.repeat(60) + '.gpx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTourGpx (PR1-06A)', () => {
|
||||
const tour = (route_geometry: string | null) => ({
|
||||
name: 'Grat <Ost>',
|
||||
description: 'Früh los',
|
||||
website: 'https://example.test/route?a=1&b=2',
|
||||
activity: 'hiking',
|
||||
route_geometry,
|
||||
stops: [{ name: 'Hütte', lat: 47.5, lng: 11.1 }],
|
||||
});
|
||||
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' });
|
||||
|
||||
it('writes the line as one track with the heights it has and none it lacks', () => {
|
||||
const gpx = buildTourGpx(
|
||||
tour(
|
||||
JSON.stringify([
|
||||
[47, 11, 600.4],
|
||||
[47.1, 11.1],
|
||||
[47.2, 11.2, 'x'],
|
||||
[47.3, 11.3, 650],
|
||||
]),
|
||||
),
|
||||
)!;
|
||||
expect(XMLValidator.validate(gpx)).toBe(true);
|
||||
const doc = parser.parse(gpx).gpx;
|
||||
expect(doc.metadata).toMatchObject({
|
||||
name: 'Grat <Ost>',
|
||||
desc: 'Früh los',
|
||||
link: { '@_href': 'https://example.test/route?a=1&b=2' },
|
||||
});
|
||||
expect(doc.trk).toMatchObject({ name: 'Grat <Ost>', type: 'hiking' });
|
||||
const points = doc.trk.trkseg.trkpt;
|
||||
expect(points).toHaveLength(4);
|
||||
expect(points.map((p: { ele?: number }) => p.ele ?? null)).toEqual([600.4, null, null, 650]);
|
||||
expect(doc.wpt).toMatchObject({ '@_lat': '47.5', '@_lon': '11.1', name: 'Hütte' });
|
||||
});
|
||||
|
||||
it('writes no description or link a tour has not got', () => {
|
||||
const gpx = buildTourGpx({
|
||||
...tour(
|
||||
JSON.stringify([
|
||||
[47, 11],
|
||||
[47.1, 11.1],
|
||||
]),
|
||||
),
|
||||
description: ' ',
|
||||
website: null,
|
||||
stops: [],
|
||||
})!;
|
||||
const doc = parser.parse(gpx).gpx;
|
||||
expect(doc.metadata).toEqual({ name: 'Grat <Ost>' });
|
||||
expect(doc.trk.desc).toBeUndefined();
|
||||
expect(doc.trk.link).toBeUndefined();
|
||||
expect(doc.wpt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a line of fewer than two points, or none at all', () => {
|
||||
expect(buildTourGpx(tour(JSON.stringify([[47, 11, 600]])))).toBeNull();
|
||||
expect(buildTourGpx(tour('not json'))).toBeNull();
|
||||
expect(buildTourGpx(tour(null))).toBeNull();
|
||||
});
|
||||
|
||||
it('names a tour whose name folds away to nothing "tour"', () => {
|
||||
expect(gpxFilename('///', 'tour')).toBe('tour.gpx');
|
||||
expect(gpxFilename('Grat <Ost>', 'tour')).toBe('Grat-Ost.gpx');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* container over a snapshot test db; the addon gate of the tools is the
|
||||
* registry's and is covered with the other addon-gated tools.
|
||||
*/
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { ToursMcp } from '../../../src/nest/tours/tours.mcp';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
@@ -17,8 +18,10 @@ import {
|
||||
createTrip,
|
||||
createUser,
|
||||
} from '../../helpers/factories';
|
||||
import { updateRows } from '../../helpers/factories/rows';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { createTestOrm } from '../../helpers/test-orm';
|
||||
import { createTour } from '../../helpers/tours-repos';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -123,4 +126,36 @@ describe('ToursMcp', () => {
|
||||
getTour.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('TOURS-MCP-006: export_tour_gpx hands over the saved line as GPX, and only to someone who sees the trip', async () => {
|
||||
const { user, member, outsider, trip, ridge, gorge } = fixture();
|
||||
const orm = await createTestOrm(db);
|
||||
await updateRows(
|
||||
orm,
|
||||
Places,
|
||||
{ id: ridge.id },
|
||||
{
|
||||
route_geometry: JSON.stringify([
|
||||
[47, 11, 600],
|
||||
[47.01, 11.01, 650],
|
||||
]),
|
||||
},
|
||||
);
|
||||
await orm.close();
|
||||
const exported = read(await mcp.exportTourGpx({ tripId: trip.id, placeId: ridge.id }, ctx(member.id)));
|
||||
expect(exported.filename).toBe('Ridge.gpx');
|
||||
expect(exported.gpx).toContain('<type>hiking</type>');
|
||||
expect(exported.gpx.match(/<trkpt /g)).toHaveLength(2);
|
||||
// The registry answers a DomainError with its message; called directly, the tool lets it through.
|
||||
await expect(mcp.exportTourGpx({ tripId: trip.id, placeId: gorge.id }, ctx(user.id))).rejects.toThrow(
|
||||
'This tour has no route to export',
|
||||
);
|
||||
expect(read(await mcp.exportTourGpx({ tripId: trip.id, placeId: ridge.id }, ctx(outsider.id)))).toEqual({
|
||||
error: 'Trip not found or access denied.',
|
||||
});
|
||||
const cafe = createPlace(db, trip.id, { name: 'Plain' });
|
||||
expect(read(await mcp.exportTourGpx({ tripId: trip.id, placeId: cafe.id }, ctx(user.id)))).toEqual({
|
||||
error: 'Tour not found.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -282,6 +282,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (نسخة)',
|
||||
'tours.copy.saved': 'تم الحفظ كجولة جديدة',
|
||||
'tours.copy.failed': 'تعذّر نسخ الجولة.',
|
||||
'tours.gpxExport.action': 'تنزيل GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'ينزّل الجولة المحفوظة كملف GPX: المسار كاملًا مع الارتفاعات المتوفرة، لتطبيق ملاحة أو جهاز GPS.',
|
||||
'tours.gpxExport.saveFirst': 'احفظ الجولة أولًا لتنزيلها بصيغة GPX',
|
||||
'tours.gpxExport.offline': 'يتطلب تنزيل ملف GPX اتصالًا بالخادم.',
|
||||
'tours.gpxExport.noRoute': 'لا يوجد لهذه الجولة مسار محفوظ للتصدير.',
|
||||
'tours.gpxExport.failed': 'تعذّر إنشاء ملف GPX.',
|
||||
'tours.planner.tourType': 'نوع الجولة',
|
||||
'tours.paddle.boat': 'القارب',
|
||||
'tours.paddle.boat.kayak': 'كاياك',
|
||||
|
||||
@@ -271,6 +271,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (surət)',
|
||||
'tours.copy.saved': 'Yeni tur kimi saxlanıldı',
|
||||
'tours.copy.failed': 'Turu kopyalamaq olmadı.',
|
||||
'tours.gpxExport.action': 'GPX yüklə',
|
||||
'tours.gpxExport.hint':
|
||||
'Saxlanmış turu GPX faylı kimi endirir: bütün marşrut mövcud hündürlüklərlə, naviqasiya tətbiqi və ya GPS cihazı üçün.',
|
||||
'tours.gpxExport.saveFirst': 'GPX kimi endirmək üçün əvvəlcə turu saxla',
|
||||
'tours.gpxExport.offline': 'GPX faylını endirmək üçün serverə bağlantı lazımdır.',
|
||||
'tours.gpxExport.noRoute': 'Bu turun ixrac ediləcək saxlanmış marşrutu yoxdur.',
|
||||
'tours.gpxExport.failed': 'GPX faylını yaratmaq olmadı.',
|
||||
'tours.planner.tourType': 'Tur növü',
|
||||
'tours.paddle.boat': 'Qayıq',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
|
||||
@@ -277,6 +277,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (cópia)',
|
||||
'tours.copy.saved': 'Salvo como novo passeio',
|
||||
'tours.copy.failed': 'Não foi possível copiar o passeio.',
|
||||
'tours.gpxExport.action': 'Baixar GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Baixa o passeio salvo como arquivo GPX: a rota inteira com as altitudes disponíveis, para um app de navegação ou um aparelho GPS.',
|
||||
'tours.gpxExport.saveFirst': 'Salve o passeio antes de baixá-lo como GPX',
|
||||
'tours.gpxExport.offline': 'Baixar um arquivo GPX exige conexão com o servidor.',
|
||||
'tours.gpxExport.noRoute': 'Este passeio não tem uma rota salva para exportar.',
|
||||
'tours.gpxExport.failed': 'Não foi possível criar o arquivo GPX.',
|
||||
'tours.planner.tourType': 'Tipo de passeio',
|
||||
'tours.paddle.boat': 'Embarcação',
|
||||
'tours.paddle.boat.kayak': 'Caiaque',
|
||||
|
||||
@@ -277,6 +277,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (còpia)',
|
||||
'tours.copy.saved': 'Desada com a excursió nova',
|
||||
'tours.copy.failed': 'No s’ha pogut copiar l’excursió.',
|
||||
'tours.gpxExport.action': 'Baixa el GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Baixa l’excursió desada com a fitxer GPX: tota la ruta amb les altituds que té, per a una aplicació de navegació o un GPS.',
|
||||
'tours.gpxExport.saveFirst': 'Desa primer l’excursió per baixar-la en GPX',
|
||||
'tours.gpxExport.offline': 'Per baixar un fitxer GPX cal connexió amb el servidor.',
|
||||
'tours.gpxExport.noRoute': 'Aquesta excursió no té cap ruta desada per exportar.',
|
||||
'tours.gpxExport.failed': 'No s’ha pogut crear el fitxer GPX.',
|
||||
'tours.planner.tourType': 'Tipus de ruta',
|
||||
'tours.paddle.boat': 'Embarcació',
|
||||
'tours.paddle.boat.kayak': 'Caiac',
|
||||
|
||||
@@ -271,6 +271,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (kopie)',
|
||||
'tours.copy.saved': 'Uloženo jako nová túra',
|
||||
'tours.copy.failed': 'Túru se nepodařilo zkopírovat.',
|
||||
'tours.gpxExport.action': 'Stáhnout GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Stáhne uloženou túru jako soubor GPX: celou trasu s dostupnými výškami, pro navigační aplikaci nebo GPS přístroj.',
|
||||
'tours.gpxExport.saveFirst': 'Nejdřív túru ulož, pak ji stáhni jako GPX',
|
||||
'tours.gpxExport.offline': 'Ke stažení souboru GPX je potřeba připojení k serveru.',
|
||||
'tours.gpxExport.noRoute': 'Tato túra nemá uloženou trasu k exportu.',
|
||||
'tours.gpxExport.failed': 'Soubor GPX se nepodařilo vytvořit.',
|
||||
'tours.planner.tourType': 'Typ túry',
|
||||
'tours.paddle.boat': 'Loď',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
|
||||
@@ -279,6 +279,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (Kopie)',
|
||||
'tours.copy.saved': 'Als neue Tour gespeichert',
|
||||
'tours.copy.failed': 'Die Tour konnte nicht kopiert werden.',
|
||||
'tours.gpxExport.action': 'GPX herunterladen',
|
||||
'tours.gpxExport.hint':
|
||||
'Lädt die gespeicherte Tour als GPX-Datei herunter: die ganze Route mit den vorhandenen Höhen, für eine Navigations-App oder ein GPS-Gerät.',
|
||||
'tours.gpxExport.saveFirst': 'Erst speichern, dann als GPX herunterladen',
|
||||
'tours.gpxExport.offline': 'Für den GPX-Download braucht es eine Verbindung zum Server.',
|
||||
'tours.gpxExport.noRoute': 'Diese Tour hat keine gespeicherte Route zum Exportieren.',
|
||||
'tours.gpxExport.failed': 'Die GPX-Datei konnte nicht erstellt werden.',
|
||||
'tours.planner.tourType': 'Tourtyp',
|
||||
'tours.paddle.boat': 'Boot',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
|
||||
@@ -270,6 +270,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (copy)',
|
||||
'tours.copy.saved': 'Saved as a new tour',
|
||||
'tours.copy.failed': 'The tour could not be copied.',
|
||||
'tours.gpxExport.action': 'Download GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Downloads the saved tour as a GPX file: its whole route with the heights it has, for a navigation app or a GPS device.',
|
||||
'tours.gpxExport.saveFirst': 'Save the tour first to download it as GPX',
|
||||
'tours.gpxExport.offline': 'Downloading a GPX file needs a connection to the server.',
|
||||
'tours.gpxExport.noRoute': 'This tour has no saved route to export.',
|
||||
'tours.gpxExport.failed': 'The GPX file could not be created.',
|
||||
'tours.planner.tourType': 'Tour type',
|
||||
'tours.paddle.boat': 'Boat',
|
||||
'tours.paddle.boat.kayak': 'Kayak',
|
||||
|
||||
@@ -276,6 +276,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (copia)',
|
||||
'tours.copy.saved': 'Guardada como nueva excursión',
|
||||
'tours.copy.failed': 'No se pudo copiar la excursión.',
|
||||
'tours.gpxExport.action': 'Descargar GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Descarga la excursión guardada como archivo GPX: toda la ruta con las altitudes disponibles, para una app de navegación o un GPS.',
|
||||
'tours.gpxExport.saveFirst': 'Guarda primero la excursión para descargarla en GPX',
|
||||
'tours.gpxExport.offline': 'Para descargar un archivo GPX hace falta conexión con el servidor.',
|
||||
'tours.gpxExport.noRoute': 'Esta excursión no tiene una ruta guardada que exportar.',
|
||||
'tours.gpxExport.failed': 'No se pudo crear el archivo GPX.',
|
||||
'tours.planner.tourType': 'Tipo de ruta',
|
||||
'tours.paddle.boat': 'Embarcación',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
|
||||
@@ -273,6 +273,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (koopia)',
|
||||
'tours.copy.saved': 'Salvestatud uue matkana',
|
||||
'tours.copy.failed': 'Matka ei õnnestunud kopeerida.',
|
||||
'tours.gpxExport.action': 'Laadi GPX alla',
|
||||
'tours.gpxExport.hint':
|
||||
'Laadib salvestatud matka alla GPX-failina: kogu marsruut olemasolevate kõrgustega, navigatsioonirakenduse või GPS-seadme jaoks.',
|
||||
'tours.gpxExport.saveFirst': 'Salvesta matk enne GPX-ina allalaadimist',
|
||||
'tours.gpxExport.offline': 'GPX-faili allalaadimiseks on vaja ühendust serveriga.',
|
||||
'tours.gpxExport.noRoute': 'Sellel matkal pole eksportimiseks salvestatud marsruuti.',
|
||||
'tours.gpxExport.failed': 'GPX-faili ei õnnestunud luua.',
|
||||
'tours.planner.tourType': 'Matka tüüp',
|
||||
'tours.paddle.boat': 'Paat',
|
||||
'tours.paddle.boat.kayak': 'Süst',
|
||||
|
||||
@@ -282,6 +282,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (copie)',
|
||||
'tours.copy.saved': 'Enregistrée comme nouvelle sortie',
|
||||
'tours.copy.failed': 'La sortie n’a pas pu être copiée.',
|
||||
'tours.gpxExport.action': 'Télécharger le GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Télécharge la sortie enregistrée en fichier GPX : tout l’itinéraire avec les altitudes disponibles, pour une app de navigation ou un GPS.',
|
||||
'tours.gpxExport.saveFirst': 'Enregistrez d’abord la sortie pour la télécharger en GPX',
|
||||
'tours.gpxExport.offline': 'Le téléchargement d’un fichier GPX nécessite une connexion au serveur.',
|
||||
'tours.gpxExport.noRoute': 'Cette sortie n’a pas d’itinéraire enregistré à exporter.',
|
||||
'tours.gpxExport.failed': 'Le fichier GPX n’a pas pu être créé.',
|
||||
'tours.planner.tourType': 'Type de sortie',
|
||||
'tours.paddle.boat': 'Embarcation',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
|
||||
@@ -283,6 +283,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (αντίγραφο)',
|
||||
'tours.copy.saved': 'Αποθηκεύτηκε ως νέα εκδρομή',
|
||||
'tours.copy.failed': 'Η εκδρομή δεν αντιγράφηκε.',
|
||||
'tours.gpxExport.action': 'Λήψη GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Κατεβάζει την αποθηκευμένη εκδρομή ως αρχείο GPX: όλη τη διαδρομή με τα διαθέσιμα υψόμετρα, για εφαρμογή πλοήγησης ή συσκευή GPS.',
|
||||
'tours.gpxExport.saveFirst': 'Αποθηκεύστε πρώτα την εκδρομή για λήψη σε GPX',
|
||||
'tours.gpxExport.offline': 'Η λήψη αρχείου GPX χρειάζεται σύνδεση με τον διακομιστή.',
|
||||
'tours.gpxExport.noRoute': 'Αυτή η εκδρομή δεν έχει αποθηκευμένη διαδρομή για εξαγωγή.',
|
||||
'tours.gpxExport.failed': 'Το αρχείο GPX δεν δημιουργήθηκε.',
|
||||
'tours.planner.tourType': 'Τύπος διαδρομής',
|
||||
'tours.paddle.boat': 'Σκάφος',
|
||||
'tours.paddle.boat.kayak': 'Καγιάκ',
|
||||
|
||||
@@ -277,6 +277,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (másolat)',
|
||||
'tours.copy.saved': 'Új túraként mentve',
|
||||
'tours.copy.failed': 'A túrát nem sikerült másolni.',
|
||||
'tours.gpxExport.action': 'GPX letöltése',
|
||||
'tours.gpxExport.hint':
|
||||
'Letölti a mentett túrát GPX-fájlként: a teljes útvonalat a meglévő magasságokkal, navigációs apphoz vagy GPS-készülékhez.',
|
||||
'tours.gpxExport.saveFirst': 'Előbb mentsd a túrát, aztán töltsd le GPX-ként',
|
||||
'tours.gpxExport.offline': 'A GPX-fájl letöltéséhez kapcsolat kell a szerverrel.',
|
||||
'tours.gpxExport.noRoute': 'Ennek a túrának nincs exportálható mentett útvonala.',
|
||||
'tours.gpxExport.failed': 'A GPX-fájlt nem sikerült létrehozni.',
|
||||
'tours.planner.tourType': 'Túratípus',
|
||||
'tours.paddle.boat': 'Hajó',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
|
||||
@@ -270,6 +270,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (salinan)',
|
||||
'tours.copy.saved': 'Disimpan sebagai tur baru',
|
||||
'tours.copy.failed': 'Tur tidak dapat disalin.',
|
||||
'tours.gpxExport.action': 'Unduh GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Mengunduh tur yang disimpan sebagai file GPX: seluruh rute dengan ketinggian yang tersedia, untuk aplikasi navigasi atau perangkat GPS.',
|
||||
'tours.gpxExport.saveFirst': 'Simpan tur dulu untuk mengunduhnya sebagai GPX',
|
||||
'tours.gpxExport.offline': 'Mengunduh file GPX memerlukan koneksi ke server.',
|
||||
'tours.gpxExport.noRoute': 'Tur ini tidak punya rute tersimpan untuk diekspor.',
|
||||
'tours.gpxExport.failed': 'File GPX tidak dapat dibuat.',
|
||||
'tours.planner.tourType': 'Jenis tur',
|
||||
'tours.paddle.boat': 'Perahu',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
|
||||
@@ -277,6 +277,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (copia)',
|
||||
'tours.copy.saved': 'Salvato come nuovo tour',
|
||||
'tours.copy.failed': 'Non è stato possibile copiare il tour.',
|
||||
'tours.gpxExport.action': 'Scarica GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Scarica il tour salvato come file GPX: tutto il percorso con le quote disponibili, per un’app di navigazione o un GPS.',
|
||||
'tours.gpxExport.saveFirst': 'Salva prima il tour per scaricarlo come GPX',
|
||||
'tours.gpxExport.offline': 'Per scaricare un file GPX serve una connessione al server.',
|
||||
'tours.gpxExport.noRoute': 'Questo tour non ha un percorso salvato da esportare.',
|
||||
'tours.gpxExport.failed': 'Non è stato possibile creare il file GPX.',
|
||||
'tours.planner.tourType': 'Tipo di tour',
|
||||
'tours.paddle.boat': 'Imbarcazione',
|
||||
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
|
||||
|
||||
@@ -264,6 +264,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name}(コピー)',
|
||||
'tours.copy.saved': '新しいツアーとして保存しました',
|
||||
'tours.copy.failed': 'ツアーをコピーできませんでした。',
|
||||
'tours.gpxExport.action': 'GPXをダウンロード',
|
||||
'tours.gpxExport.hint':
|
||||
'保存したツアーをGPXファイルとしてダウンロードします。ルート全体と記録されている標高を含み、ナビアプリやGPS機器で使えます。',
|
||||
'tours.gpxExport.saveFirst': 'GPXでダウンロードするには先にツアーを保存してください',
|
||||
'tours.gpxExport.offline': 'GPXファイルのダウンロードにはサーバーへの接続が必要です。',
|
||||
'tours.gpxExport.noRoute': 'このツアーにはエクスポートできる保存済みルートがありません。',
|
||||
'tours.gpxExport.failed': 'GPXファイルを作成できませんでした。',
|
||||
'tours.planner.tourType': 'ツアーの種類',
|
||||
'tours.paddle.boat': 'ボート',
|
||||
'tours.paddle.boat.kayak': 'カヤック',
|
||||
|
||||
@@ -262,6 +262,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (사본)',
|
||||
'tours.copy.saved': '새 투어로 저장했습니다',
|
||||
'tours.copy.failed': '투어를 복사하지 못했습니다.',
|
||||
'tours.gpxExport.action': 'GPX 다운로드',
|
||||
'tours.gpxExport.hint':
|
||||
'저장된 투어를 GPX 파일로 다운로드합니다. 경로 전체와 있는 고도 정보를 담아 내비게이션 앱이나 GPS 기기에서 쓸 수 있습니다.',
|
||||
'tours.gpxExport.saveFirst': 'GPX로 다운로드하려면 먼저 투어를 저장하세요',
|
||||
'tours.gpxExport.offline': 'GPX 파일을 다운로드하려면 서버 연결이 필요합니다.',
|
||||
'tours.gpxExport.noRoute': '이 투어에는 내보낼 저장된 경로가 없습니다.',
|
||||
'tours.gpxExport.failed': 'GPX 파일을 만들지 못했습니다.',
|
||||
'tours.planner.tourType': '투어 유형',
|
||||
'tours.paddle.boat': '보트',
|
||||
'tours.paddle.boat.kayak': '카약',
|
||||
|
||||
@@ -278,6 +278,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (kopie)',
|
||||
'tours.copy.saved': 'Opgeslagen als nieuwe tocht',
|
||||
'tours.copy.failed': 'De tocht kon niet worden gekopieerd.',
|
||||
'tours.gpxExport.action': 'GPX downloaden',
|
||||
'tours.gpxExport.hint':
|
||||
'Downloadt de opgeslagen tocht als GPX-bestand: de hele route met de beschikbare hoogtes, voor een navigatie-app of een gps-apparaat.',
|
||||
'tours.gpxExport.saveFirst': 'Sla de tocht eerst op om hem als GPX te downloaden',
|
||||
'tours.gpxExport.offline': 'Voor het downloaden van een GPX-bestand is een verbinding met de server nodig.',
|
||||
'tours.gpxExport.noRoute': 'Deze tocht heeft geen opgeslagen route om te exporteren.',
|
||||
'tours.gpxExport.failed': 'Het GPX-bestand kon niet worden gemaakt.',
|
||||
'tours.planner.tourType': 'Soort tocht',
|
||||
'tours.paddle.boat': 'Boot',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
|
||||
@@ -284,6 +284,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (kopia)',
|
||||
'tours.copy.saved': 'Zapisano jako nową trasę',
|
||||
'tours.copy.failed': 'Nie udało się skopiować trasy.',
|
||||
'tours.gpxExport.action': 'Pobierz GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Pobiera zapisaną trasę jako plik GPX: cały przebieg z dostępnymi wysokościami, do aplikacji nawigacyjnej lub urządzenia GPS.',
|
||||
'tours.gpxExport.saveFirst': 'Najpierw zapisz trasę, aby pobrać ją jako GPX',
|
||||
'tours.gpxExport.offline': 'Pobranie pliku GPX wymaga połączenia z serwerem.',
|
||||
'tours.gpxExport.noRoute': 'Ta trasa nie ma zapisanego przebiegu do eksportu.',
|
||||
'tours.gpxExport.failed': 'Nie udało się utworzyć pliku GPX.',
|
||||
'tours.planner.tourType': 'Rodzaj wycieczki',
|
||||
'tours.paddle.boat': 'Łódź',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
|
||||
@@ -287,6 +287,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (копия)',
|
||||
'tours.copy.saved': 'Сохранено как новый поход',
|
||||
'tours.copy.failed': 'Не удалось скопировать поход.',
|
||||
'tours.gpxExport.action': 'Скачать GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Скачивает сохранённый поход как файл GPX: весь маршрут с имеющимися высотами, для навигационного приложения или GPS-устройства.',
|
||||
'tours.gpxExport.saveFirst': 'Сначала сохраните поход, чтобы скачать его в GPX',
|
||||
'tours.gpxExport.offline': 'Для скачивания файла GPX нужно соединение с сервером.',
|
||||
'tours.gpxExport.noRoute': 'У этого похода нет сохранённого маршрута для экспорта.',
|
||||
'tours.gpxExport.failed': 'Не удалось создать файл GPX.',
|
||||
'tours.planner.tourType': 'Тип тура',
|
||||
'tours.paddle.boat': 'Лодка',
|
||||
'tours.paddle.boat.kayak': 'Каяк',
|
||||
|
||||
@@ -272,6 +272,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (kópia)',
|
||||
'tours.copy.saved': 'Uložené ako nová túra',
|
||||
'tours.copy.failed': 'Túru sa nepodarilo skopírovať.',
|
||||
'tours.gpxExport.action': 'Stiahnuť GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Stiahne uloženú túru ako súbor GPX: celú trasu s dostupnými výškami, pre navigačnú aplikáciu alebo GPS zariadenie.',
|
||||
'tours.gpxExport.saveFirst': 'Najprv túru ulož, potom ju stiahni ako GPX',
|
||||
'tours.gpxExport.offline': 'Na stiahnutie súboru GPX je potrebné pripojenie k serveru.',
|
||||
'tours.gpxExport.noRoute': 'Táto túra nemá uloženú trasu na export.',
|
||||
'tours.gpxExport.failed': 'Súbor GPX sa nepodarilo vytvoriť.',
|
||||
'tours.planner.tourType': 'Typ túry',
|
||||
'tours.paddle.boat': 'Loď',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
|
||||
@@ -269,6 +269,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (kopia)',
|
||||
'tours.copy.saved': 'Sparad som ny tur',
|
||||
'tours.copy.failed': 'Turen kunde inte kopieras.',
|
||||
'tours.gpxExport.action': 'Ladda ner GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Laddar ner den sparade turen som GPX-fil: hela rutten med de höjder som finns, för en navigeringsapp eller en GPS-enhet.',
|
||||
'tours.gpxExport.saveFirst': 'Spara turen först för att ladda ner den som GPX',
|
||||
'tours.gpxExport.offline': 'För att ladda ner en GPX-fil behövs en anslutning till servern.',
|
||||
'tours.gpxExport.noRoute': 'Den här turen har ingen sparad rutt att exportera.',
|
||||
'tours.gpxExport.failed': 'GPX-filen kunde inte skapas.',
|
||||
'tours.planner.tourType': 'Turtyp',
|
||||
'tours.paddle.boat': 'Båt',
|
||||
'tours.paddle.boat.kayak': 'Kajak',
|
||||
|
||||
@@ -258,6 +258,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (สำเนา)',
|
||||
'tours.copy.saved': 'บันทึกเป็นทัวร์ใหม่แล้ว',
|
||||
'tours.copy.failed': 'คัดลอกทัวร์ไม่สำเร็จ',
|
||||
'tours.gpxExport.action': 'ดาวน์โหลด GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'ดาวน์โหลดทัวร์ที่บันทึกไว้เป็นไฟล์ GPX ทั้งเส้นทางพร้อมความสูงที่มี สำหรับแอปนำทางหรืออุปกรณ์ GPS',
|
||||
'tours.gpxExport.saveFirst': 'บันทึกทัวร์ก่อนเพื่อดาวน์โหลดเป็น GPX',
|
||||
'tours.gpxExport.offline': 'การดาวน์โหลดไฟล์ GPX ต้องเชื่อมต่อกับเซิร์ฟเวอร์',
|
||||
'tours.gpxExport.noRoute': 'ทัวร์นี้ไม่มีเส้นทางที่บันทึกไว้ให้ส่งออก',
|
||||
'tours.gpxExport.failed': 'สร้างไฟล์ GPX ไม่สำเร็จ',
|
||||
'tours.planner.tourType': 'ประเภททริป',
|
||||
'tours.paddle.boat': 'เรือ',
|
||||
'tours.paddle.boat.kayak': 'เรือคายัก',
|
||||
|
||||
@@ -271,6 +271,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (kopya)',
|
||||
'tours.copy.saved': 'Yeni tur olarak kaydedildi',
|
||||
'tours.copy.failed': 'Tur kopyalanamadı.',
|
||||
'tours.gpxExport.action': 'GPX indir',
|
||||
'tours.gpxExport.hint':
|
||||
'Kayıtlı turu GPX dosyası olarak indirir: mevcut yükseltileriyle tüm rota, bir navigasyon uygulaması ya da GPS cihazı için.',
|
||||
'tours.gpxExport.saveFirst': 'GPX olarak indirmek için önce turu kaydet',
|
||||
'tours.gpxExport.offline': 'GPX dosyası indirmek için sunucuya bağlantı gerekir.',
|
||||
'tours.gpxExport.noRoute': 'Bu turun dışa aktarılacak kayıtlı bir rotası yok.',
|
||||
'tours.gpxExport.failed': 'GPX dosyası oluşturulamadı.',
|
||||
'tours.planner.tourType': 'Tur türü',
|
||||
'tours.paddle.boat': 'Tekne',
|
||||
'tours.paddle.boat.kayak': 'Kano (kayak)',
|
||||
|
||||
@@ -284,6 +284,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (копія)',
|
||||
'tours.copy.saved': 'Збережено як новий похід',
|
||||
'tours.copy.failed': 'Не вдалося скопіювати похід.',
|
||||
'tours.gpxExport.action': 'Завантажити GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Завантажує збережений похід як файл GPX: увесь маршрут із наявними висотами, для навігаційного застосунку чи GPS-пристрою.',
|
||||
'tours.gpxExport.saveFirst': 'Спершу збережіть похід, щоб завантажити його в GPX',
|
||||
'tours.gpxExport.offline': 'Для завантаження файлу GPX потрібне з’єднання із сервером.',
|
||||
'tours.gpxExport.noRoute': 'У цього походу немає збереженого маршруту для експорту.',
|
||||
'tours.gpxExport.failed': 'Не вдалося створити файл GPX.',
|
||||
'tours.planner.tourType': 'Тип туру',
|
||||
'tours.paddle.boat': 'Човен',
|
||||
'tours.paddle.boat.kayak': 'Каяк',
|
||||
|
||||
@@ -271,6 +271,13 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name} (bản sao)',
|
||||
'tours.copy.saved': 'Đã lưu thành chuyến đi bộ mới',
|
||||
'tours.copy.failed': 'Không thể sao chép chuyến đi bộ.',
|
||||
'tours.gpxExport.action': 'Tải GPX',
|
||||
'tours.gpxExport.hint':
|
||||
'Tải chuyến đi bộ đã lưu dưới dạng tệp GPX: toàn bộ tuyến đường kèm độ cao hiện có, dùng cho ứng dụng dẫn đường hoặc thiết bị GPS.',
|
||||
'tours.gpxExport.saveFirst': 'Hãy lưu chuyến đi bộ trước để tải về dạng GPX',
|
||||
'tours.gpxExport.offline': 'Cần kết nối tới máy chủ để tải tệp GPX.',
|
||||
'tours.gpxExport.noRoute': 'Chuyến đi bộ này không có tuyến đường đã lưu để xuất.',
|
||||
'tours.gpxExport.failed': 'Không thể tạo tệp GPX.',
|
||||
'tours.planner.tourType': 'Loại chuyến',
|
||||
'tours.paddle.boat': 'Thuyền',
|
||||
'tours.paddle.boat.kayak': 'Thuyền kayak',
|
||||
|
||||
@@ -248,6 +248,12 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name}(副本)',
|
||||
'tours.copy.saved': '已另存為新遊程',
|
||||
'tours.copy.failed': '無法複製遊程。',
|
||||
'tours.gpxExport.action': '下載 GPX',
|
||||
'tours.gpxExport.hint': '將已儲存的遊程下載為 GPX 檔案:包含完整路線與現有的海拔資料,可用於導航 App 或 GPS 裝置。',
|
||||
'tours.gpxExport.saveFirst': '請先儲存遊程,再下載為 GPX',
|
||||
'tours.gpxExport.offline': '下載 GPX 檔案需要連線到伺服器。',
|
||||
'tours.gpxExport.noRoute': '這個遊程沒有可匯出的已儲存路線。',
|
||||
'tours.gpxExport.failed': '無法建立 GPX 檔案。',
|
||||
'tours.planner.tourType': '路線類型',
|
||||
'tours.paddle.boat': '船隻',
|
||||
'tours.paddle.boat.kayak': '獨木舟(Kayak)',
|
||||
|
||||
@@ -249,6 +249,12 @@ const tours: TranslationStrings = {
|
||||
'tours.copy.name': '{name}(副本)',
|
||||
'tours.copy.saved': '已另存为新路线',
|
||||
'tours.copy.failed': '无法复制路线。',
|
||||
'tours.gpxExport.action': '下载 GPX',
|
||||
'tours.gpxExport.hint': '将已保存的路线下载为 GPX 文件:包含完整路线和已有的海拔数据,可用于导航应用或 GPS 设备。',
|
||||
'tours.gpxExport.saveFirst': '请先保存路线,再下载为 GPX',
|
||||
'tours.gpxExport.offline': '下载 GPX 文件需要连接服务器。',
|
||||
'tours.gpxExport.noRoute': '这条路线没有可导出的已保存线路。',
|
||||
'tours.gpxExport.failed': '无法创建 GPX 文件。',
|
||||
'tours.planner.tourType': '路线类型',
|
||||
'tours.paddle.boat': '船只',
|
||||
'tours.paddle.boat.kayak': '皮划艇',
|
||||
|
||||
@@ -240,6 +240,7 @@ Read only: the saved tours of a trip, as the Tours list and the tour detail show
|
||||
| `list_tours` | The saved tours of a trip: name, type (`hike`, or `kayak` for a paddle tour), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale, the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), and the days of the trip each tour is on | `trips:read` |
|
||||
| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`) or a stretch drawn by hand (`free`), which no router planned or checked. A shaping point (`shaping: true`) only steers the route and is no stop. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The full track geometry is not included | `trips:read` |
|
||||
| `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` |
|
||||
| `export_tour_gpx` | One saved tour as a GPX file: its whole saved line as one track, with the heights it carries and none it lacks, and a file name. A tour without a line cannot be exported | `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