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:
Maurice
2026-10-19 09:42:13 +02:00
parent e29aa99269
commit 134ab64bce
46 changed files with 1028 additions and 34 deletions
+33
View File
@@ -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']) };
},
};
+6 -3
View File
@@ -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 };
}
+15
View File
@@ -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) };
}
}
+19 -1
View File
@@ -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);
+22
View File
@@ -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;
}
}
}
+2
View File
@@ -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,
],
+254
View File
@@ -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');
});
});
+35
View File
@@ -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.',
});
});
});
+7
View File
@@ -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': 'كاياك',
+7
View File
@@ -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
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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
+7
View File
@@ -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',
+7
View File
@@ -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
+7
View File
@@ -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': 'Καγιάκ',
+7
View File
@@ -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',
+7
View File
@@ -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
+7
View File
@@ -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
+7
View File
@@ -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': 'カヤック',
+7
View File
@@ -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': '카약',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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': 'Каяк',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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': 'เรือคายัก',
+7
View File
@@ -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)',
+7
View File
@@ -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': 'Каяк',
+7
View File
@@ -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',
+6
View File
@@ -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)',
+6
View File
@@ -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': '皮划艇',
+1
View File
@@ -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)_