feat(tours): take a tour over from another trip, the dialog (LIB-02)

The Tours tab's list of the trip's tours gains a button beside the GPX
import: take a tour over from another trip. Its dialog lists the tours of
the user's other trips, searched by name and filtered by trip and type,
each with its trip and the measures it has (time, distance, ascent; one it
lacks is left out, a paddle tour's climb too). Choosing one shows it on a
map with where it comes from, its type, distance, time, heights and number
of waypoints, and the name it gets in this trip, its own name to start
with. The note under the name says the copy is a tour of this trip of its
own, on no day yet, and that later changes stay apart.

Only the button in the foot takes it over. The new tour then comes into
the trip's lists and map and opens in the editor, unless the editor holds
unsaved work, which it never replaces. Nothing is written before the
confirm, and closing writes nothing. The library is online only: offline,
or when the list fails, the dialog says so and offers to try again; a list
cut at 100 tours says to search or filter.

A tour taken over names its source in its provenance: "Taken over from"
with the trip and the tour as they were called then.
This commit is contained in:
Maurice
2026-10-19 15:28:53 +02:00
parent f6fd2722d0
commit 692373c8b8
39 changed files with 1545 additions and 6 deletions
+18
View File
@@ -0,0 +1,18 @@
import type {
TourCreateResponse,
TourLibraryDetailResponse,
TourLibraryQuery,
TourLibraryResponse,
TourTakeOverRequest,
} from '@trek/shared';
import apiClient from './client';
/** LIB-02: the tours of the user's other trips, one's preview, and taking one over into a trip. */
export const tourLibraryApi = {
list: (query: TourLibraryQuery, signal?: AbortSignal): Promise<TourLibraryResponse> =>
apiClient.get('/tours/library', { params: query, signal }).then((r) => r.data),
detail: (placeId: number, signal?: AbortSignal): Promise<TourLibraryDetailResponse> =>
apiClient.get(`/tours/library/${placeId}`, { signal }).then((r) => r.data),
takeOver: (tripId: number | string, body: TourTakeOverRequest): Promise<TourCreateResponse> =>
apiClient.post(`/trips/${tripId}/tours/take-over`, body).then((r) => r.data),
};
@@ -0,0 +1,388 @@
import type { TourLibraryItem, TourListItem, TourTypeKey } from '@trek/shared';
import { AlertTriangle, ArrowUp, Clock, CopyPlus, Library, Loader2, Ruler, Search, WifiOff } from 'lucide-react';
import { useId, useMemo } from 'react';
import { OFM_POSITRON } from '../../../constants/mapDefaults';
import { useTileUrl } from '../../../hooks/useTileUrl';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance, formatElevation } from '../../../utils/units';
import { MapViewAuto } from '../../Map/MapViewAuto';
import { BarButton } from '../../Planner/planParts';
import { EditorField, INPUT, PillSelect } from '../../shared/dialogParts';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { TourDayRowPill } from '../TourDayRowFacts';
import { RailFacts, TourNotice, type TourMetricField } from '../tourParts';
import { formatPlannedTourDuration, tourPlannedTimes } from '../tourPresentation';
import { useTourLibrary, type TourLibrary } from './useTourLibrary';
type Translate = (key: string, params?: Record<string, unknown>) => string;
type Unit = Parameters<typeof formatDistance>[1];
const ALL = 'all';
/** A tour's own measures as badges; one it does not have is left out, never shown as zero. */
function measureBadges(tour: TourLibraryItem, unit: Unit, t: Translate) {
const minutes = tourPlannedTimes(tour).plannedTotalMinutes;
return [
minutes != null && {
key: 'time',
icon: Clock,
value: formatPlannedTourDuration(minutes),
label: `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(minutes)}`,
},
tour.distance != null && {
key: 'distance',
icon: Ruler,
value: formatDistance(tour.distance, unit),
label: `${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`,
},
tour.elevation_gain != null &&
tour.tour_type !== 'kayak' && {
key: 'ascent',
icon: ArrowUp,
value: formatElevation(tour.elevation_gain, unit),
label: `${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`,
},
].filter((badge) => !!badge);
}
/** One tour of the list: its name, the trip it is in, its measures. */
function LibraryRow({ tour, selected, onChoose }: { tour: TourLibraryItem; selected: boolean; onChoose: () => void }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
return (
<li>
<button
type="button"
onClick={onChoose}
aria-pressed={selected}
data-testid={`tour-library-row-${tour.place_id}`}
className={`flex w-full min-w-0 flex-col gap-1 rounded-[10px] px-2.5 py-2 text-start transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${selected ? 'bg-surface-selected' : 'hover:bg-surface-hover'}`}
>
<span className="flex min-w-0 items-baseline gap-2">
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
{tour.name}
</span>
<span className="flex-none text-content-faint" style={fs(11, 'body')}>
{t(`tourTypes.${tour.tour_type}`)}
</span>
</span>
<span className="min-w-0 truncate text-content-muted" style={fs(11, 'body')}>
{tour.trip.title}
</span>
<span className="flex flex-wrap gap-1.5">
{measureBadges(tour, unit, t).map((badge) => (
<TourDayRowPill
key={badge.key}
icon={badge.icon}
value={badge.value}
label={badge.label}
testId={`tour-library-${badge.key}`}
/>
))}
</span>
</button>
</li>
);
}
/** A line of the list's state: loading, offline, failed with a retry, empty, or cut short. */
function ListState({ library }: { library: TourLibrary }) {
const { t } = useTranslation();
const { listStatus, list } = library;
if (listStatus === 'offline')
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.library.offline')}</TourNotice>;
if (listStatus === 'error')
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-2">
{t('tours.library.loadFailed')}
<button type="button" onClick={library.retry} className="font-semibold underline underline-offset-2">
{t('tours.planner.retry')}
</button>
</span>
</TourNotice>
);
if (listStatus === 'loading' && !list)
return (
<p role="status" className="m-0 flex items-center gap-2 py-4 text-content-faint" style={fs(12, 'body')}>
<Loader2 size={13} strokeWidth={2.2} className="animate-spin" />
{t('common.loading')}
</p>
);
if (list && list.tours.length === 0)
return (
<p className="m-0 py-4 text-center text-content-muted" style={fs(12.5, 'body')}>
{list.trips.length === 0 ? t('tours.library.noTrips') : t('tours.library.empty')}
</p>
);
if (list?.truncated) return <TourNotice>{t('tours.library.truncated')}</TourNotice>;
return null;
}
/** The search box and the two filters over the list. */
function ListFilters({ library }: { library: TourLibrary }) {
const { t } = useTranslation();
const searchId = useId();
const trips = library.list?.trips ?? [];
const tripOptions = [
{ value: ALL, label: t('tours.library.allTrips') },
...trips.map((trip) => ({ value: String(trip.id), label: trip.title, hint: String(trip.tour_count) })),
];
const typeOptions = [
{ value: ALL, label: t('tours.library.allTypes') },
...(['hike', 'kayak'] as const).map((type) => ({ value: type, label: t(`tourTypes.${type}`) })),
];
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 rounded-[10px] border border-edge bg-surface-input px-2.5 py-2">
<Search size={13} strokeWidth={2.2} className="flex-none text-content-faint" />
<label htmlFor={searchId} className="sr-only">
{t('tours.library.search')}
</label>
<input
id={searchId}
autoFocus
value={library.filters.query}
onChange={(event) => library.filter({ query: event.target.value })}
placeholder={t('tours.library.search')}
className="min-w-0 flex-1 border-0 bg-transparent text-content outline-none placeholder:text-content-faint"
style={fs(12.5, 'body')}
/>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<PillSelect
quiet
label={t('tours.library.trip')}
value={library.filters.tripFilter === null ? ALL : String(library.filters.tripFilter)}
options={tripOptions}
onChange={(value) => library.filter({ tripFilter: value === ALL ? null : Number(value) })}
/>
<PillSelect
quiet
label={t('tours.filter.type')}
value={library.filters.typeFilter ?? ALL}
options={typeOptions}
onChange={(value) => library.filter({ typeFilter: value === ALL ? null : (value as TourTypeKey) })}
/>
</div>
</div>
);
}
/** The facts of the chosen tour as the preview shows them, each only where the tour has it. */
function previewFacts(tour: TourLibraryItem, waypoints: number, unit: Unit, t: Translate): TourMetricField[] {
const minutes = tourPlannedTimes(tour).plannedTotalMinutes;
const fact = (show: boolean, label: string, value: () => string) => (show ? { label, value: value() } : null);
return [
fact(true, t('tours.library.fromTrip'), () => tour.trip.title),
fact(true, t('tours.filter.type'), () => t(`tourTypes.${tour.tour_type}`)),
fact(tour.distance != null, t('tours.detail.distance'), () => formatDistance(tour.distance ?? 0, unit)),
fact(minutes != null, t('tours.planner.plannedTotalDuration'), () => formatPlannedTourDuration(minutes ?? 0)),
fact(tour.elevation_gain != null && tour.tour_type !== 'kayak', t('tours.detail.ascent'), () =>
formatElevation(tour.elevation_gain ?? 0, unit)
),
fact(tour.elevation_loss != null && tour.tour_type !== 'kayak', t('tours.detail.descent'), () =>
formatElevation(tour.elevation_loss ?? 0, unit)
),
fact(waypoints > 0, t('tours.library.controlPoints'), () => String(waypoints)),
].filter((field): field is TourMetricField => field !== null);
}
/** The chosen tour: on a map, its facts, and the name the copy gets. */
function Preview({ library }: { library: TourLibrary }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const tileUrl = useTileUrl(OFM_POSITRON);
const nameId = useId();
const line = library.preview?.line;
const route = useMemo(
// The client reads the contract's tuples loosely; the points are spelled out as pairs here.
() =>
line && line.length > 1 ? [line.map((point): [number, number] => [Number(point[0]), Number(point[1])])] : [],
[line]
);
const { selected } = library;
if (!selected)
return (
<div className="grid h-full min-h-[240px] place-items-center rounded-[14px] border border-dashed border-edge px-6 text-center">
<p className="m-0 max-w-[260px] text-content-muted" style={fs(12.5, 'body')}>
{t('tours.library.chooseHint')}
</p>
</div>
);
return (
<div className="flex min-w-0 flex-col gap-3" data-testid="tour-library-preview">
<div className="relative h-[240px] flex-none overflow-hidden rounded-[14px] border border-edge-faint bg-surface-secondary">
{route.length > 0 && (
<MapViewAuto
places={[]}
route={route}
tileUrl={tileUrl}
focusPoints={route[0]}
focusKey={String(selected.place_id)}
followSelection={false}
/>
)}
{library.previewStatus === 'loading' && (
<span className="absolute inset-0 grid place-items-center text-content-faint">
<Loader2 size={18} strokeWidth={2.2} className="animate-spin" />
</span>
)}
{(library.previewStatus === 'error' || library.previewStatus === 'offline') && (
<span
className="absolute inset-0 grid place-items-center px-6 text-center text-content-muted"
style={fs(12, 'body')}
>
{t(library.previewStatus === 'offline' ? 'tours.library.offline' : 'tours.library.previewFailed')}
</span>
)}
</div>
<RailFacts facts={previewFacts(selected, library.preview?.waypoint_count ?? 0, unit, t)} />
<EditorField label={t('tours.library.nameHere')} htmlFor={nameId} hint={t('tours.library.independent')}>
<input
id={nameId}
className={INPUT}
value={library.name}
maxLength={255}
onChange={(event) => library.setName(event.target.value)}
/>
</EditorField>
</div>
);
}
/**
* LIB-02: the dialog to take a tour of another trip over into this one. The
* list on the left, the chosen tour on the right; the copy is made only by the
* button in its foot.
*/
export function TourLibraryDialog({ library }: { library: TourLibrary }) {
const { t } = useTranslation();
const labelId = useId();
if (!library.open) return null;
return (
<DialogShell
onClose={library.close}
labelledBy={labelId}
width="wide"
align="top"
blocked={library.taking}
header={
<DialogHeader
tile={
<DialogTile>
<Library size={20} strokeWidth={2} className="text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={library.close}
eyebrow={t('tours.library.eyebrow')}
title={t('tours.library.title')}
sub={t('tours.library.sub')}
subWraps
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={library.close} disabled={library.taking}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
data-testid="tour-library-confirm"
icon={
library.taking ? (
<Loader2 size={14} strokeWidth={2} className="animate-spin" />
) : (
<CopyPlus size={14} strokeWidth={2} />
)
}
disabled={!library.canConfirm}
onClick={() => void library.confirm()}
>
{t('tours.library.confirm')}
</DialogButton>
</DialogFooter>
}
>
<div className="grid min-h-0 grid-cols-1 gap-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
<section aria-label={t('tours.library.title')} className="flex min-w-0 flex-col gap-3">
<ListFilters library={library} />
<ListState library={library} />
{library.list && library.list.tours.length > 0 && (
<ul
className={`m-0 flex max-h-[420px] list-none flex-col gap-0.5 overflow-y-auto p-0 transition-opacity ${library.listStatus === 'loading' ? 'opacity-60' : ''}`}
data-testid="tour-library-list"
>
{library.list.tours.map((tour) => (
<LibraryRow
key={tour.place_id}
tour={tour}
selected={library.selected?.place_id === tour.place_id}
onChoose={() => library.choose(tour)}
/>
))}
</ul>
)}
</section>
<Preview library={library} />
</div>
</DialogShell>
);
}
/** The rail head's way in, beside the GPX import: a button, and the dialog it opens. */
export function TourLibraryButton({ library, disabled }: { library: TourLibrary; disabled?: boolean }) {
const { t } = useTranslation();
return (
<>
<BarButton
label={t('tours.library.open')}
testId="tour-rail-library"
disabled={disabled}
onClick={library.show}
className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<Library size={16} strokeWidth={2} />
</BarButton>
<TourLibraryDialog library={library} />
</>
);
}
/**
* The tours rail's way in, with the dialog's state: a tour taken over comes
* into the trip and opens in the editor, unless the editor holds unsaved work.
*/
export function TourLibraryEntry({
planner,
onTakenOver,
disabled,
}: {
planner: Pick<TourPlannerController, 'tripId' | 'hasUnsavedChanges' | 'openTour'>;
/** Where the tour goes into the trip; without it there is no way in. */
onTakenOver?: (tour: TourListItem) => Promise<void>;
disabled?: boolean;
}) {
const library = useTourLibrary({
tripId: Number(planner.tripId),
onTakenOver: async (tour) => {
await onTakenOver?.(tour);
if (!planner.hasUnsavedChanges) void planner.openTour(tour);
},
});
return onTakenOver ? <TourLibraryButton library={library} disabled={disabled} /> : null;
}
@@ -0,0 +1,189 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import type { TourCreateResponse, TourLibraryItem, TourLibraryResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { TourLibraryOfflineError, tourLibraryRepo } from '../../../repo/tourLibraryRepo';
import { TourLibraryButton } from './TourLibraryDialog';
import { useTourLibrary } from './useTourLibrary';
// FE-TOUR-LIBRARY-001 to FE-TOUR-LIBRARY-008
vi.mock('../../Map/MapViewAuto', () => ({
MapViewAuto: ({ route }: { route: Array<Array<[number, number]>> }) => (
<div data-testid="library-map" data-points={route[0]?.length ?? 0} />
),
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
function item(over: Partial<TourLibraryItem> & Pick<TourLibraryItem, 'place_id' | 'name'>): TourLibraryItem {
return {
description: null,
tour_type: 'hike',
distance: 8.2,
elevation_gain: 600,
elevation_loss: 590,
duration: 180,
planned_duration_minutes: null,
break_additional_minutes: null,
max_hiking_difficulty: 2,
has_waypoints: true,
origin: 'trek',
trip: { id: 2, title: 'Alps' },
...over,
} as TourLibraryItem;
}
const RIDGE = item({ place_id: 11, name: 'Ridge' });
const LAKE = item({
place_id: 12,
name: 'Lake loop',
tour_type: 'kayak',
distance: null,
elevation_gain: 40,
trip: { id: 3, title: 'Lakes' },
});
const LIST: TourLibraryResponse = {
tours: [RIDGE, LAKE],
trips: [
{ id: 3, title: 'Lakes', tour_count: 1 },
{ id: 2, title: 'Alps', tour_count: 1 },
],
truncated: false,
};
let list: ReturnType<typeof vi.spyOn>;
let detail: ReturnType<typeof vi.spyOn>;
let takeOver: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
toast.success.mockReset();
toast.error.mockReset();
list = vi.spyOn(tourLibraryRepo, 'list').mockResolvedValue(LIST);
detail = vi.spyOn(tourLibraryRepo, 'detail').mockResolvedValue({
tour: RIDGE,
line: [
[47, 11],
[47.1, 11.1],
],
waypoint_count: 4,
});
takeOver = vi.spyOn(tourLibraryRepo, 'takeOver').mockResolvedValue({
tour: { place_id: 99, name: 'My ridge' },
waypoints: [],
} as unknown as TourCreateResponse);
});
afterEach(() => vi.restoreAllMocks());
describe('useTourLibrary', () => {
it('FE-TOUR-LIBRARY-001: asks nothing until opened, then lists the other trips’ tours, the asking trip left out', async () => {
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
expect(list).not.toHaveBeenCalled();
act(() => result.current.show());
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
expect(list).toHaveBeenCalledWith({ exclude_trip_id: 5 }, expect.any(AbortSignal));
expect(result.current.list?.tours).toHaveLength(2);
});
it('FE-TOUR-LIBRARY-002: narrows by name after a pause, and by trip and type at once', async () => {
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
act(() => result.current.show());
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
act(() => result.current.filter({ query: ' rid ', tripFilter: 2, typeFilter: 'hike' }));
expect(result.current.listStatus).toBe('loading');
await waitFor(() =>
expect(list).toHaveBeenLastCalledWith(
{ q: 'rid', trip_id: 2, type: 'hike', exclude_trip_id: 5 },
expect.any(AbortSignal)
)
);
});
it('FE-TOUR-LIBRARY-003: says when it is offline or failed, and asks again on retry', async () => {
list.mockRejectedValueOnce(new TourLibraryOfflineError()).mockRejectedValueOnce(new Error('500'));
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
act(() => result.current.show());
await waitFor(() => expect(result.current.listStatus).toBe('offline'));
act(() => result.current.retry());
await waitFor(() => expect(result.current.listStatus).toBe('error'));
act(() => result.current.retry());
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
});
it('FE-TOUR-LIBRARY-004: previews the tour chosen and takes it over under the name given, then hands it in', async () => {
const onTakenOver = vi.fn();
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver }));
act(() => result.current.show());
act(() => result.current.choose(RIDGE));
expect(result.current.name).toBe('Ridge');
await waitFor(() => expect(result.current.previewStatus).toBe('ready'));
expect(detail).toHaveBeenCalledWith(11, expect.any(AbortSignal));
act(() => result.current.setName(' My ridge '));
await act(() => result.current.confirm());
expect(takeOver).toHaveBeenCalledWith(5, { source_trip_id: 2, source_place_id: 11, name: 'My ridge' });
expect(onTakenOver).toHaveBeenCalledWith(expect.objectContaining({ place_id: 99 }));
expect(result.current.open).toBe(false);
expect(toast.success).toHaveBeenCalled();
});
it('FE-TOUR-LIBRARY-005: nothing is taken over without a name, and a failed take-over keeps the dialog', async () => {
takeOver.mockRejectedValueOnce(new Error('500'));
const onTakenOver = vi.fn();
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver }));
act(() => result.current.show());
act(() => result.current.choose(RIDGE));
act(() => result.current.setName(' '));
expect(result.current.canConfirm).toBe(false);
await act(() => result.current.confirm());
expect(takeOver).not.toHaveBeenCalled();
act(() => result.current.setName('Ridge'));
await act(() => result.current.confirm());
expect(onTakenOver).not.toHaveBeenCalled();
expect(result.current.open).toBe(true);
expect(toast.error).toHaveBeenCalled();
});
});
describe('TourLibraryDialog', () => {
function Harness({ onTakenOver = vi.fn() }: { onTakenOver?: () => void }) {
const library = useTourLibrary({ tripId: 5, onTakenOver });
return <TourLibraryButton library={library} />;
}
it('FE-TOUR-LIBRARY-006: lists the tours with their trip and measures, a missing one left out', async () => {
render(<Harness />);
fireEvent.click(screen.getByTestId('tour-rail-library'));
const ridge = await screen.findByTestId('tour-library-row-11');
expect(ridge).toHaveTextContent('Ridge');
expect(ridge).toHaveTextContent('Alps');
expect(within(ridge).getByTestId('tour-library-distance')).toHaveTextContent('8.2 km');
const lake = screen.getByTestId('tour-library-row-12');
// No distance known, and a paddle tour's climb says nothing: neither is shown.
expect(within(lake).queryByTestId('tour-library-distance')).toBeNull();
expect(within(lake).queryByTestId('tour-library-ascent')).toBeNull();
expect(screen.getByTestId('tour-library-confirm')).toBeDisabled();
});
it('FE-TOUR-LIBRARY-007: choosing a tour shows it on a map with its facts and the name field; the button takes it over', async () => {
const onTakenOver = vi.fn();
render(<Harness onTakenOver={onTakenOver} />);
fireEvent.click(screen.getByTestId('tour-rail-library'));
fireEvent.click(await screen.findByTestId('tour-library-row-11'));
const preview = screen.getByTestId('tour-library-preview');
expect(await within(preview).findByTestId('library-map')).toHaveAttribute('data-points', '2');
expect(preview).toHaveTextContent('Alps');
expect(preview).toHaveTextContent('4');
expect(within(preview).getByDisplayValue('Ridge')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('tour-library-confirm'));
await waitFor(() => expect(onTakenOver).toHaveBeenCalled());
expect(screen.queryByTestId('tour-library-preview')).toBeNull();
});
it('FE-TOUR-LIBRARY-008: says when the other trips have no tours', async () => {
list.mockResolvedValue({ tours: [], trips: [], truncated: false });
render(<Harness />);
fireEvent.click(screen.getByTestId('tour-rail-library'));
expect(await screen.findByText('Your other trips have no tours yet.')).toBeInTheDocument();
});
});
@@ -0,0 +1,180 @@
import type {
TourLibraryDetailResponse,
TourLibraryItem,
TourLibraryResponse,
TourListItem,
TourTypeKey,
} from '@trek/shared';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { TourLibraryOfflineError, tourLibraryRepo } from '../../../repo/tourLibraryRepo';
import { useToast } from '../../shared/Toast';
/** How long typing rests before the list is asked again. */
const SEARCH_PAUSE_MS = 250;
type LoadStatus = 'loading' | 'ready' | 'error' | 'offline';
/** An answer with the request it belongs to, so a newer request reads as loading, never as an older answer. */
interface Answer<T> {
key: string;
status: Exclude<LoadStatus, 'loading'>;
data?: T;
}
const failure = (err: unknown): 'error' | 'offline' => (err instanceof TourLibraryOfflineError ? 'offline' : 'error');
interface ListFilters {
query: string;
tripFilter: number | null;
typeFilter: TourTypeKey | null;
}
/** The list while the dialog is open: asked at once on opening, then after a pause in typing; the asking trip left out. */
function useLibraryList(
open: boolean,
tripId: number,
{ query, tripFilter, typeFilter }: ListFilters,
attempt: number
) {
const [answer, setAnswer] = useState<Answer<TourLibraryResponse> | null>(null);
const askedRef = useRef(false);
const q = query.trim();
const key = JSON.stringify([q, tripFilter, typeFilter, attempt]);
useEffect(() => {
if (!open) {
askedRef.current = false;
return undefined;
}
const controller = new AbortController();
const ask = () =>
tourLibraryRepo
.list(
{
q: q || undefined,
trip_id: tripFilter ?? undefined,
type: typeFilter ?? undefined,
exclude_trip_id: tripId,
},
controller.signal
)
.then((data) => setAnswer({ key, status: 'ready', data }))
.catch((err: unknown) => {
if (!controller.signal.aborted) setAnswer({ key, status: failure(err) });
});
const timer = window.setTimeout(() => void ask(), askedRef.current ? SEARCH_PAUSE_MS : 0);
askedRef.current = true;
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [open, key, q, tripFilter, typeFilter, tripId]);
return {
status: (answer?.key === key ? answer.status : 'loading') as LoadStatus,
/** The last list that came in, kept while a newer one loads so the rows do not flicker away. */
data: answer?.data ?? null,
};
}
/** The preview of the tour chosen, read anew for each choice. */
function useLibraryPreview(open: boolean, selected: TourLibraryItem | null, attempt: number) {
const [answer, setAnswer] = useState<Answer<TourLibraryDetailResponse> | null>(null);
const placeId = selected?.place_id ?? null;
const key = `${placeId ?? ''}:${attempt}`;
useEffect(() => {
if (!open || placeId === null) return undefined;
const controller = new AbortController();
tourLibraryRepo
.detail(placeId, controller.signal)
.then((data) => setAnswer({ key, status: 'ready', data }))
.catch((err: unknown) => {
if (!controller.signal.aborted) setAnswer({ key, status: failure(err) });
});
return () => controller.abort();
}, [open, placeId, key]);
const status: LoadStatus | null = placeId === null ? null : answer?.key === key ? answer.status : 'loading';
return { status, data: status === 'ready' ? (answer?.data ?? null) : null };
}
/**
* LIB-02: taking a tour of another trip over into this one. The dialog lists
* the tours of the user's other trips, searched by name and filtered by trip
* and type, previews the one chosen, and takes it over only on an explicit
* confirm, under the name in the field. Nothing is written before that, and
* closing writes nothing. The new tour then comes into the trip through
* `onTakenOver`, which also opens it.
*/
export function useTourLibrary({
tripId,
onTakenOver,
}: {
tripId: number;
onTakenOver: (tour: TourListItem) => void | Promise<void>;
}) {
const { t } = useTranslation();
const toast = useToast();
const [open, setOpen] = useState(false);
const [filters, setFilters] = useState<ListFilters>({ query: '', tripFilter: null, typeFilter: null });
const [selected, setSelected] = useState<TourLibraryItem | null>(null);
const [name, setName] = useState('');
const [taking, setTaking] = useState(false);
const [attempt, setAttempt] = useState(0);
const list = useLibraryList(open, tripId, filters, attempt);
const preview = useLibraryPreview(open, selected, attempt);
const reset = () => {
setOpen(false);
setSelected(null);
setFilters({ query: '', tripFilter: null, typeFilter: null });
};
const close = () => {
if (!taking) reset();
};
const choose = (tour: TourLibraryItem) => {
setSelected(tour);
setName(tour.name);
};
const filter = (change: Partial<ListFilters>) => setFilters((current) => ({ ...current, ...change }));
const confirm = async () => {
const chosenName = name.trim();
if (!selected || !chosenName || taking) return;
setTaking(true);
try {
const result = await tourLibraryRepo.takeOver(tripId, {
source_trip_id: selected.trip.id,
source_place_id: selected.place_id,
name: chosenName,
});
setTaking(false);
reset();
await onTakenOver(result.tour);
toast.success(t('tours.library.taken', { name: result.tour.name }));
} catch (err: unknown) {
setTaking(false);
toast.error(t(failure(err) === 'offline' ? 'tours.library.offline' : 'tours.library.failed'));
}
};
return {
open,
show: useCallback(() => setOpen(true), []),
close,
filters,
filter,
listStatus: list.status,
list: list.data,
selected,
choose,
previewStatus: preview.status,
preview: preview.data,
name,
setName,
taking,
canConfirm: !!selected && name.trim().length > 0 && !taking,
confirm,
retry: () => setAttempt((value) => value + 1),
};
}
export type TourLibrary = ReturnType<typeof useTourLibrary>;
@@ -43,6 +43,7 @@ import { TourRemoteChangeNotice, TourRevisionConflictDialog } from './TourRevisi
import TourListFilterMenu, { TourFilterEmpty } from '../TourListFilterMenu'
import { TourScopeSummary } from '../TourScopeSummary'
import { useTourListFilters } from '../useTourListFilters'
import { TourLibraryEntry } from '../library/TourLibraryDialog'
const STATUS_KEY: Partial<Record<TourPlannerStatus, string>> = {
dirty: 'tours.planner.status.dirty',
@@ -609,6 +610,8 @@ interface TourPlannerToursRailProps extends TourPermissionProps {
onDeleteTour?: (placeId: number) => void
/** The workspace's GPX import; its button sits beside "New tour". */
gpxImport?: TourGpxImport
/** LIB-02: takes a tour of another trip in; its button sits beside the import. */
onTakenOver?: (tour: TourListItem) => Promise<void>
}
/** The time facts of the overview: the estimate (walking, or paddling for a paddle tour), the breaks and the planned total. */
@@ -641,7 +644,7 @@ function plannerTimeFields(planner: TourPlannerController, t: Translate) {
}
export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, onDeleteTour,
gpxImport, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
gpxImport, onTakenOver, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) {
const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
const { t } = useTranslation()
const toast = useToast()
@@ -693,6 +696,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
<div className={RAIL_HEAD} style={{ background: NEUTRAL_TINT }}>
<RailTitle title={t('tours.planner.tripTours')} />
{canEdit && gpxImport && <RailImportButton gpxImport={gpxImport} disabled={isSaving} />}
{canEdit && <TourLibraryEntry planner={planner} onTakenOver={onTakenOver} disabled={isSaving} />}
{canEdit && planner.mode.type !== 'neutral' && (
<BarButton label={t('tours.planner.newTour')} testId="tour-rail-new-tour" disabled={isSaving}
onClick={() => { if (!isSaving) planner.startNewTour() }}
@@ -39,6 +39,16 @@ describe('TourProvenanceSection (P2C-02, MF-03)', () => {
expect(screen.queryByRole('button', { name: /tours.provenance.check/ })).toBeNull();
});
it('names the trip and tour a tour was taken over from, and nothing for one that was not (LIB-02)', () => {
const { rerender } = render(
<TourProvenanceSection provenance={{ ...base, takenOverFrom: { tripTitle: 'Alps', tourName: 'Ridge' } }} />
);
expect(screen.getByText('tours.provenance.takenOverFrom')).toBeInTheDocument();
expect(screen.getByTestId('tour-provenance-taken-over')).toHaveTextContent('Alps: Ridge');
rerender(<TourProvenanceSection provenance={{ ...base, takenOverFrom: null }} />);
expect(screen.queryByTestId('tour-provenance-taken-over')).toBeNull();
});
it('shows a GPX import with its file, and that it was planned over keeping its track', () => {
render(
<TourProvenanceSection
@@ -1,5 +1,5 @@
import type { TourListItem, TourProvenance, TourSourceCheckResponse } from '@trek/shared';
import { Download, ExternalLink, FileDown, Loader2, PenLine, RefreshCw } from 'lucide-react';
import { CopyPlus, Download, ExternalLink, FileDown, Loader2, PenLine, RefreshCw } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { formatDate } from '../../../utils/formatters';
@@ -224,6 +224,16 @@ export default function TourProvenanceSection({ provenance, check }: TourProvena
<ProvenanceRow icon={sourceIcon} label={t('tours.provenance.source')}>
<SourceValue provenance={provenance} t={t} />
</ProvenanceRow>
{provenance.takenOverFrom && (
// LIB-02: the trip and tour it was taken from, as they were called then.
<ProvenanceRow icon={<CopyPlus size={13} strokeWidth={2} />} label={t('tours.provenance.takenOverFrom')}>
<span data-testid="tour-provenance-taken-over" className="min-w-0 truncate">
<span className="font-semibold">{provenance.takenOverFrom.tripTitle}</span>
{': '}
{provenance.takenOverFrom.tourName}
</span>
</ProvenanceRow>
)}
{importedAt && (
<ProvenanceRow
icon={<Download size={13} strokeWidth={2} />}
+1
View File
@@ -687,6 +687,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onDeleteTour={handleDeleteTour}
gpxImport={tourGpxImport}
onViewGpxTour={viewImportedTour}
onTakenOver={tourDetailActions.onTakenOver}
/>
</LazyPanel>
)}
@@ -13,11 +13,15 @@ import type { useTripPlanner } from './useTripPlanner';
export function useTourDetailActions(planner: ReturnType<typeof useTripPlanner>) {
const { invalidateTourPlaceIds, reloadTourPlaceIds, t, toast, tripActions, tripId, upsertTour } = planner;
/** A tour that just came in: in the lists and on the map, then open. */
const takeIn = async (tour: TourListItem) => {
/** A tour that just came in: in the lists and on the map. */
const bringIn = async (tour: TourListItem) => {
upsertTour(tour);
await useTripStore.getState().refreshPlaces(tripId);
void invalidateTourPlaceIds({ placeIds: [tour.place_id] });
};
/** ...and its detail open. */
const takeIn = async (tour: TourListItem) => {
await bringIn(tour);
planner.handlePlaceClick(tour.place_id);
};
@@ -27,6 +31,8 @@ export function useTourDetailActions(planner: ReturnType<typeof useTripPlanner>)
await reloadTourPlaceIds();
},
onSourceReimported: takeIn,
/** LIB-02: a tour taken over from another trip; the tours rail opens it in the planner. */
onTakenOver: bringIn,
onSaveAsNew: async (tour: TourListItem) => {
try {
const name = t('tours.copy.name', { name: tour.name }).slice(0, 255);
+39
View File
@@ -0,0 +1,39 @@
import type { TourCreateResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { tourLibraryApi } from '../api/tourLibrary';
import { TourLibraryOfflineError, tourLibraryRepo } from './tourLibraryRepo';
// FE-REPO-TOUR-LIBRARY-001 to FE-REPO-TOUR-LIBRARY-002
const network = vi.hoisted(() => ({ offline: false }));
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: () => network.offline }));
const cache = vi.hoisted(() => ({ cacheTourAnswer: vi.fn() }));
vi.mock('../db/cacheTour', () => cache);
beforeEach(() => {
network.offline = false;
cache.cacheTourAnswer.mockReset();
vi.restoreAllMocks();
});
describe('tourLibraryRepo', () => {
it('FE-REPO-TOUR-LIBRARY-001: reads and takes over only online, never from a cache', async () => {
network.offline = true;
const list = vi.spyOn(tourLibraryApi, 'list');
await expect(tourLibraryRepo.list({})).rejects.toBeInstanceOf(TourLibraryOfflineError);
await expect(tourLibraryRepo.detail(3)).rejects.toBeInstanceOf(TourLibraryOfflineError);
await expect(tourLibraryRepo.takeOver(5, { source_trip_id: 2, source_place_id: 3 })).rejects.toBeInstanceOf(
TourLibraryOfflineError
);
expect(list).not.toHaveBeenCalled();
});
it('FE-REPO-TOUR-LIBRARY-002: a tour taken over goes into this trip’s cache', async () => {
const answer = { tour: { place_id: 9 }, waypoints: [] } as unknown as TourCreateResponse;
vi.spyOn(tourLibraryApi, 'takeOver').mockResolvedValue(answer);
vi.spyOn(tourLibraryApi, 'detail').mockResolvedValue({ tour: {} as never, line: [], waypoint_count: 0 });
expect(await tourLibraryRepo.takeOver('5', { source_trip_id: 2, source_place_id: 3 })).toBe(answer);
expect(cache.cacheTourAnswer).toHaveBeenCalledWith(5, answer);
expect((await tourLibraryRepo.detail(3)).waypoint_count).toBe(0);
});
});
+48
View File
@@ -0,0 +1,48 @@
import type {
TourCreateResponse,
TourLibraryDetailResponse,
TourLibraryQuery,
TourLibraryResponse,
TourTakeOverRequest,
} from '@trek/shared';
import { tourLibraryApi } from '../api/tourLibrary';
import { cacheTourAnswer } from '../db/cacheTour';
import { isEffectivelyOffline } from '../sync/networkMode';
/** What a library call answers while there is no connection: nothing read, and the view says why. */
export class TourLibraryOfflineError extends Error {
constructor() {
super('The tour library needs a connection');
this.name = 'TourLibraryOfflineError';
}
}
function requireOnline(): void {
if (isEffectivelyOffline()) throw new TourLibraryOfflineError();
}
/**
* LIB-02, online only on purpose: the list reads across trips this device may
* never have cached and whose access the server decides at the moment it is
* asked, so an offline copy could show a tour of a trip the user has left.
* The take-over is made from the server's copy of the source, like the in-trip
* copy, and the new tour goes into this trip's cache once it exists.
*/
export const tourLibraryRepo = {
async list(query: TourLibraryQuery, signal?: AbortSignal): Promise<TourLibraryResponse> {
requireOnline();
return await tourLibraryApi.list(query, signal);
},
async detail(placeId: number, signal?: AbortSignal): Promise<TourLibraryDetailResponse> {
requireOnline();
return await tourLibraryApi.detail(placeId, signal);
},
async takeOver(tripId: number | string, body: TourTakeOverRequest): Promise<TourCreateResponse> {
requireOnline();
const result = await tourLibraryApi.takeOver(tripId, body);
await cacheTourAnswer(Number(tripId), result);
return result;
},
};
+24
View File
@@ -284,6 +284,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'الوقت من الاستيراد',
'tours.scope.planned': 'المخطط مع الاستراحات',
'tours.scope.noMeasures': 'لا مسافة أو ارتفاعات أو أوقات معروفة بعد.',
'tours.library.open': 'نقل جولة من رحلة أخرى',
'tours.library.eyebrow': 'نقل جولة',
'tours.library.title': 'من رحلة أخرى',
'tours.library.sub': 'ينسخ جولة من رحلاتك الأخرى إلى هذه الرحلة. يبقى الأصل كما هو.',
'tours.library.search': 'البحث بالاسم',
'tours.library.trip': 'الرحلة',
'tours.library.allTrips': 'كل الرحلات',
'tours.library.allTypes': 'كل الأنواع',
'tours.library.empty': 'لا توجد جولة مطابقة.',
'tours.library.noTrips': 'لا توجد جولات في رحلاتك الأخرى بعد.',
'tours.library.truncated': 'تُعرض أول 100 جولة. ابحث أو صفِّ للعثور على البقية.',
'tours.library.offline': 'يتطلب نقل جولة اتصالًا بالإنترنت.',
'tours.library.loadFailed': 'تعذّر تحميل الجولات.',
'tours.library.previewFailed': 'تعذّر تحميل المعاينة.',
'tours.library.chooseHint': 'اختر جولة لتراها هنا قبل نقلها.',
'tours.library.fromTrip': 'من الرحلة',
'tours.library.controlPoints': 'نقاط الطريق',
'tours.library.nameHere': 'الاسم في هذه الرحلة',
'tours.library.independent':
'تصبح جولة مستقلة في هذه الرحلة: ليست في أي يوم بعد، وتبقى التغييرات اللاحقة على أي منهما منفصلة.',
'tours.library.confirm': 'نقل',
'tours.library.taken': 'تم نقل «{name}» إلى هذه الرحلة.',
'tours.library.failed': 'تعذّر نقل الجولة.',
'tours.provenance.takenOverFrom': 'منقولة من',
'tours.copy.action': 'حفظ كجولة جديدة',
'tours.copy.hint': 'ينسخ هذه الجولة كجولة جديدة مستقلة دون أيامها. يبقى الأصل كما هو.',
'tours.copy.name': '{name} (نسخة)',
+24
View File
@@ -273,6 +273,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'İdxaldan vaxt',
'tours.scope.planned': 'Fasilələrlə planlaşdırılan',
'tours.scope.noMeasures': 'Hələ məsafə, hündürlük və ya vaxt məlum deyil.',
'tours.library.open': 'Başqa səfərdən tur götür',
'tours.library.eyebrow': 'Turu götür',
'tours.library.title': 'Başqa səfərdən',
'tours.library.sub': 'Digər səfərlərindən bir turu bu səfərə köçürür. Orijinal olduğu kimi qalır.',
'tours.library.search': 'Ada görə axtar',
'tours.library.trip': 'Səfər',
'tours.library.allTrips': 'Bütün səfərlər',
'tours.library.allTypes': 'Bütün növlər',
'tours.library.empty': 'Uyğun tur yoxdur.',
'tours.library.noTrips': 'Digər səfərlərində hələ tur yoxdur.',
'tours.library.truncated': 'İlk 100 tur göstərilir. Qalanlarını tapmaq üçün axtar və ya süz.',
'tours.library.offline': 'Turu götürmək üçün bağlantı lazımdır.',
'tours.library.loadFailed': 'Turlar yüklənə bilmədi.',
'tours.library.previewFailed': 'Önizləmə yüklənə bilmədi.',
'tours.library.chooseHint': 'Götürməzdən əvvəl burada görmək üçün tur seç.',
'tours.library.fromTrip': 'Səfərdən',
'tours.library.controlPoints': 'Yol nöqtələri',
'tours.library.nameHere': 'Bu səfərdəki ad',
'tours.library.independent':
'Bu səfərin öz turu olur: hələ heç bir gündə deyil və sonrakı dəyişikliklər hər birində ayrı qalır.',
'tours.library.confirm': 'Götür',
'tours.library.taken': '“{name}” bu səfərə götürüldü.',
'tours.library.failed': 'Tur götürülə bilmədi.',
'tours.provenance.takenOverFrom': 'Götürüldüyü yer',
'tours.copy.action': 'Yeni tur kimi saxla',
'tours.copy.hint': 'Bu turu günləri olmadan yeni, müstəqil tur kimi kopyalayır. Orijinal olduğu kimi qalır.',
'tours.copy.name': '{name} (surət)',
+24
View File
@@ -278,6 +278,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Tempo das importações',
'tours.scope.planned': 'Planejado com pausas',
'tours.scope.noMeasures': 'Ainda sem distância, altitudes ou tempos conhecidos.',
'tours.library.open': 'Trazer um passeio de outra viagem',
'tours.library.eyebrow': 'Trazer passeio',
'tours.library.title': 'De outra viagem',
'tours.library.sub': 'Copia um passeio das suas outras viagens para esta viagem. O original continua como está.',
'tours.library.search': 'Pesquisar por nome',
'tours.library.trip': 'Viagem',
'tours.library.allTrips': 'Todas as viagens',
'tours.library.allTypes': 'Todos os tipos',
'tours.library.empty': 'Nenhum passeio corresponde.',
'tours.library.noTrips': 'Suas outras viagens ainda não têm passeios.',
'tours.library.truncated': 'Mostrando os primeiros 100 passeios. Pesquise ou filtre para encontrar os outros.',
'tours.library.offline': 'Trazer um passeio precisa de conexão.',
'tours.library.loadFailed': 'Não foi possível carregar os passeios.',
'tours.library.previewFailed': 'Não foi possível carregar a prévia.',
'tours.library.chooseHint': 'Escolha um passeio para vê-lo aqui antes de trazê-lo.',
'tours.library.fromTrip': 'Da viagem',
'tours.library.controlPoints': 'Pontos de rota',
'tours.library.nameHere': 'Nome nesta viagem',
'tours.library.independent':
'Vira um passeio próprio desta viagem: ainda em nenhum dia, e mudanças futuras em um não afetam o outro.',
'tours.library.confirm': 'Trazer',
'tours.library.taken': '“{name}” foi trazido para esta viagem.',
'tours.library.failed': 'Não foi possível trazer o passeio.',
'tours.provenance.takenOverFrom': 'Trazido de',
'tours.copy.action': 'Salvar como novo passeio',
'tours.copy.hint':
'Copia este passeio como um novo passeio independente, sem os seus dias. O original fica como está.',
+24
View File
@@ -279,6 +279,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Temps de les importacions',
'tours.scope.planned': 'Planificat amb pauses',
'tours.scope.noMeasures': 'Encara no hi ha distància, alçades ni temps coneguts.',
'tours.library.open': "Agafar una ruta d'un altre viatge",
'tours.library.eyebrow': 'Agafar una ruta',
'tours.library.title': "D'un altre viatge",
'tours.library.sub': "Copia una ruta dels teus altres viatges a aquest viatge. L'original es queda tal com és.",
'tours.library.search': 'Cerca per nom',
'tours.library.trip': 'Viatge',
'tours.library.allTrips': 'Tots els viatges',
'tours.library.allTypes': 'Tots els tipus',
'tours.library.empty': 'Cap ruta coincideix.',
'tours.library.noTrips': 'Els teus altres viatges encara no tenen rutes.',
'tours.library.truncated': 'Es mostren les primeres 100 rutes. Cerca o filtra per trobar les altres.',
'tours.library.offline': 'Per agafar una ruta cal connexió.',
'tours.library.loadFailed': "No s'han pogut carregar les rutes.",
'tours.library.previewFailed': "No s'ha pogut carregar la vista prèvia.",
'tours.library.chooseHint': "Tria una ruta per veure-la aquí abans d'agafar-la.",
'tours.library.fromTrip': 'Del viatge',
'tours.library.controlPoints': 'Punts de ruta',
'tours.library.nameHere': 'Nom en aquest viatge',
'tours.library.independent':
"Esdevé una ruta pròpia d'aquest viatge: encara en cap dia, i els canvis posteriors en qualsevol de les dues es mantenen separats.",
'tours.library.confirm': 'Agafar',
'tours.library.taken': "S'ha agafat «{name}» en aquest viatge.",
'tours.library.failed': "No s'ha pogut agafar la ruta.",
'tours.provenance.takenOverFrom': 'Agafada de',
'tours.copy.action': 'Desa com a excursió nova',
'tours.copy.hint':
'Copia aquesta excursió com una excursió nova i independent, sense els seus dies. L’original es queda com està.',
+24
View File
@@ -273,6 +273,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Čas z importů',
'tours.scope.planned': 'Plán s přestávkami',
'tours.scope.noMeasures': 'Zatím není známa vzdálenost, výšky ani časy.',
'tours.library.open': 'Převzít túru z jiné cesty',
'tours.library.eyebrow': 'Převzít túru',
'tours.library.title': 'Z jiné cesty',
'tours.library.sub': 'Zkopíruje túru z tvých jiných cest do této cesty. Originál zůstane beze změny.',
'tours.library.search': 'Hledat podle názvu',
'tours.library.trip': 'Cesta',
'tours.library.allTrips': 'Všechny cesty',
'tours.library.allTypes': 'Všechny typy',
'tours.library.empty': 'Žádná túra neodpovídá.',
'tours.library.noTrips': 'Tvoje jiné cesty zatím nemají žádné túry.',
'tours.library.truncated': 'Zobrazuje se prvních 100 túr. Ostatní najdeš hledáním nebo filtrem.',
'tours.library.offline': 'Převzetí túry potřebuje připojení.',
'tours.library.loadFailed': 'Túry se nepodařilo načíst.',
'tours.library.previewFailed': 'Náhled se nepodařilo načíst.',
'tours.library.chooseHint': 'Vyber túru a prohlédni si ji tady, než ji převezmeš.',
'tours.library.fromTrip': 'Z cesty',
'tours.library.controlPoints': 'Body trasy',
'tours.library.nameHere': 'Název v této cestě',
'tours.library.independent':
'Stane se samostatnou túrou této cesty: zatím v žádném dni a pozdější změny jedné z nich zůstanou oddělené.',
'tours.library.confirm': 'Převzít',
'tours.library.taken': '„{name}“ byla převzata do této cesty.',
'tours.library.failed': 'Túru se nepodařilo převzít.',
'tours.provenance.takenOverFrom': 'Převzato z',
'tours.copy.action': 'Uložit jako novou túru',
'tours.copy.hint': 'Zkopíruje tuto túru jako novou, samostatnou túru bez jejích dnů. Originál zůstane, jak je.',
'tours.copy.name': '{name} (kopie)',
+24
View File
@@ -281,6 +281,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Zeit laut Import',
'tours.scope.planned': 'Geplant mit Pausen',
'tours.scope.noMeasures': 'Noch keine Distanz, Höhen oder Zeiten bekannt.',
'tours.library.open': 'Tour aus anderer Reise übernehmen',
'tours.library.eyebrow': 'Tour übernehmen',
'tours.library.title': 'Aus einer anderen Reise',
'tours.library.sub': 'Kopiert eine Tour aus deinen anderen Reisen in diese Reise. Das Original bleibt, wie es ist.',
'tours.library.search': 'Nach Name suchen',
'tours.library.trip': 'Reise',
'tours.library.allTrips': 'Alle Reisen',
'tours.library.allTypes': 'Alle Arten',
'tours.library.empty': 'Keine Tour passt.',
'tours.library.noTrips': 'Deine anderen Reisen haben noch keine Touren.',
'tours.library.truncated': 'Es werden die ersten 100 Touren gezeigt. Suche oder filtere, um die übrigen zu finden.',
'tours.library.offline': 'Zum Übernehmen einer Tour braucht es eine Verbindung.',
'tours.library.loadFailed': 'Die Touren konnten nicht geladen werden.',
'tours.library.previewFailed': 'Die Vorschau konnte nicht geladen werden.',
'tours.library.chooseHint': 'Wähle eine Tour, um sie hier vor dem Übernehmen anzusehen.',
'tours.library.fromTrip': 'Aus der Reise',
'tours.library.controlPoints': 'Wegpunkte',
'tours.library.nameHere': 'Name in dieser Reise',
'tours.library.independent':
'Sie wird eine eigene Tour dieser Reise: noch an keinem Tag, und spätere Änderungen an einer der beiden bleiben getrennt.',
'tours.library.confirm': 'Übernehmen',
'tours.library.taken': '„{name}“ wurde in diese Reise übernommen.',
'tours.library.failed': 'Die Tour konnte nicht übernommen werden.',
'tours.provenance.takenOverFrom': 'Übernommen aus',
'tours.copy.action': 'Als neue Tour speichern',
'tours.copy.hint': 'Kopiert diese Tour als neue, eigenständige Tour ohne ihre Tage. Das Original bleibt, wie es ist.',
'tours.copy.name': '{name} (Kopie)',
+24
View File
@@ -272,6 +272,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Time from imports',
'tours.scope.planned': 'Planned with breaks',
'tours.scope.noMeasures': 'No distance, heights or times known yet.',
'tours.library.open': 'Take over a tour from another trip',
'tours.library.eyebrow': 'Take over a tour',
'tours.library.title': 'From another trip',
'tours.library.sub': 'Copies a tour of your other trips into this trip. The original stays as it is.',
'tours.library.search': 'Search by name',
'tours.library.trip': 'Trip',
'tours.library.allTrips': 'All trips',
'tours.library.allTypes': 'All types',
'tours.library.empty': 'No tour matches.',
'tours.library.noTrips': 'Your other trips have no tours yet.',
'tours.library.truncated': 'Showing the first 100 tours. Search or filter to find the others.',
'tours.library.offline': 'Taking a tour over needs a connection.',
'tours.library.loadFailed': 'The tours could not be loaded.',
'tours.library.previewFailed': 'The preview could not be loaded.',
'tours.library.chooseHint': 'Choose a tour to see it here before taking it over.',
'tours.library.fromTrip': 'From the trip',
'tours.library.controlPoints': 'Waypoints',
'tours.library.nameHere': 'Name in this trip',
'tours.library.independent':
'It becomes a tour of this trip of its own: on no day yet, and later changes to either stay apart.',
'tours.library.confirm': 'Take over',
'tours.library.taken': '“{name}” was taken over into this trip.',
'tours.library.failed': 'The tour could not be taken over.',
'tours.provenance.takenOverFrom': 'Taken over from',
'tours.copy.action': 'Save as new tour',
'tours.copy.hint': 'Copies this tour as a new, independent tour without its days. The original stays as it is.',
'tours.copy.name': '{name} (copy)',
+24
View File
@@ -278,6 +278,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Tiempo de las importaciones',
'tours.scope.planned': 'Planificado con pausas',
'tours.scope.noMeasures': 'Aún no se conocen distancia, alturas ni tiempos.',
'tours.library.open': 'Traer una ruta de otro viaje',
'tours.library.eyebrow': 'Traer una ruta',
'tours.library.title': 'De otro viaje',
'tours.library.sub': 'Copia una ruta de tus otros viajes a este viaje. El original se queda como está.',
'tours.library.search': 'Buscar por nombre',
'tours.library.trip': 'Viaje',
'tours.library.allTrips': 'Todos los viajes',
'tours.library.allTypes': 'Todos los tipos',
'tours.library.empty': 'Ninguna ruta coincide.',
'tours.library.noTrips': 'Tus otros viajes aún no tienen rutas.',
'tours.library.truncated': 'Se muestran las primeras 100 rutas. Busca o filtra para encontrar las demás.',
'tours.library.offline': 'Traer una ruta necesita conexión.',
'tours.library.loadFailed': 'No se pudieron cargar las rutas.',
'tours.library.previewFailed': 'No se pudo cargar la vista previa.',
'tours.library.chooseHint': 'Elige una ruta para verla aquí antes de traerla.',
'tours.library.fromTrip': 'Del viaje',
'tours.library.controlPoints': 'Puntos de ruta',
'tours.library.nameHere': 'Nombre en este viaje',
'tours.library.independent':
'Se convierte en una ruta propia de este viaje: aún en ningún día, y los cambios posteriores en cualquiera de las dos quedan separados.',
'tours.library.confirm': 'Traer',
'tours.library.taken': '«{name}» se trajo a este viaje.',
'tours.library.failed': 'No se pudo traer la ruta.',
'tours.provenance.takenOverFrom': 'Traída de',
'tours.copy.action': 'Guardar como nueva excursión',
'tours.copy.hint':
'Copia esta excursión como una nueva excursión independiente, sin sus días. El original queda como está.',
+24
View File
@@ -275,6 +275,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Aeg importidest',
'tours.scope.planned': 'Planeeritud koos pausidega',
'tours.scope.noMeasures': 'Vahemaa, kõrgused ja ajad pole veel teada.',
'tours.library.open': 'Võta matk üle teiselt reisilt',
'tours.library.eyebrow': 'Matka ülevõtmine',
'tours.library.title': 'Teiselt reisilt',
'tours.library.sub': 'Kopeerib matka sinu teistelt reisidelt sellele reisile. Originaal jääb samaks.',
'tours.library.search': 'Otsi nime järgi',
'tours.library.trip': 'Reis',
'tours.library.allTrips': 'Kõik reisid',
'tours.library.allTypes': 'Kõik tüübid',
'tours.library.empty': 'Ükski matk ei sobi.',
'tours.library.noTrips': 'Sinu teistel reisidel pole veel matku.',
'tours.library.truncated': 'Näidatakse esimest 100 matka. Ülejäänute leidmiseks otsi või filtreeri.',
'tours.library.offline': 'Matka ülevõtmiseks on vaja ühendust.',
'tours.library.loadFailed': 'Matku ei õnnestunud laadida.',
'tours.library.previewFailed': 'Eelvaadet ei õnnestunud laadida.',
'tours.library.chooseHint': 'Vali matk, et seda enne ülevõtmist siin näha.',
'tours.library.fromTrip': 'Reisilt',
'tours.library.controlPoints': 'Teekonnapunktid',
'tours.library.nameHere': 'Nimi sellel reisil',
'tours.library.independent':
'Sellest saab selle reisi eraldi matk: veel üheski päevas mitte, ja hilisemad muudatused jäävad kummaski eraldi.',
'tours.library.confirm': 'Võta üle',
'tours.library.taken': '„{name}“ võeti sellele reisile üle.',
'tours.library.failed': 'Matka ei õnnestunud üle võtta.',
'tours.provenance.takenOverFrom': 'Üle võetud',
'tours.copy.action': 'Salvesta uue matkana',
'tours.copy.hint': 'Kopeerib selle matka uue, iseseisva matkana ilma päevadeta. Originaal jääb samaks.',
'tours.copy.name': '{name} (koopia)',
+24
View File
@@ -284,6 +284,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Temps des imports',
'tours.scope.planned': 'Prévu avec pauses',
'tours.scope.noMeasures': "Aucune distance, altitude ni durée connue pour l'instant.",
'tours.library.open': "Reprendre un itinéraire d'un autre voyage",
'tours.library.eyebrow': 'Reprendre un itinéraire',
'tours.library.title': "D'un autre voyage",
'tours.library.sub': "Copie un itinéraire de tes autres voyages dans ce voyage. L'original reste tel quel.",
'tours.library.search': 'Rechercher par nom',
'tours.library.trip': 'Voyage',
'tours.library.allTrips': 'Tous les voyages',
'tours.library.allTypes': 'Tous les types',
'tours.library.empty': 'Aucun itinéraire ne correspond.',
'tours.library.noTrips': "Tes autres voyages n'ont pas encore d'itinéraires.",
'tours.library.truncated': 'Les 100 premiers itinéraires sont affichés. Recherche ou filtre pour trouver les autres.',
'tours.library.offline': 'Reprendre un itinéraire nécessite une connexion.',
'tours.library.loadFailed': "Les itinéraires n'ont pas pu être chargés.",
'tours.library.previewFailed': "L'aperçu n'a pas pu être chargé.",
'tours.library.chooseHint': 'Choisis un itinéraire pour le voir ici avant de le reprendre.',
'tours.library.fromTrip': 'Du voyage',
'tours.library.controlPoints': 'Points de passage',
'tours.library.nameHere': 'Nom dans ce voyage',
'tours.library.independent':
"Il devient un itinéraire propre à ce voyage : encore sur aucun jour, et les changements ultérieurs de l'un ou l'autre restent séparés.",
'tours.library.confirm': 'Reprendre',
'tours.library.taken': '« {name} » a été repris dans ce voyage.',
'tours.library.failed': "L'itinéraire n'a pas pu être repris.",
'tours.provenance.takenOverFrom': 'Repris de',
'tours.copy.action': 'Enregistrer comme nouvelle sortie',
'tours.copy.hint':
'Copie cette sortie en une nouvelle sortie indépendante, sans ses jours. L’original reste tel quel.',
+24
View File
@@ -285,6 +285,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Χρόνος από εισαγωγές',
'tours.scope.planned': 'Με διαλείμματα',
'tours.scope.noMeasures': 'Δεν είναι ακόμη γνωστά απόσταση, υψόμετρα ή χρόνοι.',
'tours.library.open': 'Μεταφορά διαδρομής από άλλο ταξίδι',
'tours.library.eyebrow': 'Μεταφορά διαδρομής',
'tours.library.title': 'Από άλλο ταξίδι',
'tours.library.sub': 'Αντιγράφει μια διαδρομή από τα άλλα σου ταξίδια σε αυτό το ταξίδι. Το πρωτότυπο μένει ως έχει.',
'tours.library.search': 'Αναζήτηση με όνομα',
'tours.library.trip': 'Ταξίδι',
'tours.library.allTrips': 'Όλα τα ταξίδια',
'tours.library.allTypes': 'Όλοι οι τύποι',
'tours.library.empty': 'Καμία διαδρομή δεν ταιριάζει.',
'tours.library.noTrips': 'Τα άλλα σου ταξίδια δεν έχουν ακόμη διαδρομές.',
'tours.library.truncated': 'Εμφανίζονται οι πρώτες 100 διαδρομές. Αναζήτησε ή φίλτραρε για να βρεις τις υπόλοιπες.',
'tours.library.offline': 'Η μεταφορά διαδρομής χρειάζεται σύνδεση.',
'tours.library.loadFailed': 'Δεν ήταν δυνατή η φόρτωση των διαδρομών.',
'tours.library.previewFailed': 'Δεν ήταν δυνατή η φόρτωση της προεπισκόπησης.',
'tours.library.chooseHint': 'Διάλεξε μια διαδρομή για να τη δεις εδώ πριν τη μεταφέρεις.',
'tours.library.fromTrip': 'Από το ταξίδι',
'tours.library.controlPoints': 'Σημεία διαδρομής',
'tours.library.nameHere': 'Όνομα σε αυτό το ταξίδι',
'tours.library.independent':
'Γίνεται ξεχωριστή διαδρομή αυτού του ταξιδιού: ακόμη σε καμία μέρα, και οι μεταγενέστερες αλλαγές σε οποιαδήποτε μένουν χωριστές.',
'tours.library.confirm': 'Μεταφορά',
'tours.library.taken': 'Το «{name}» μεταφέρθηκε σε αυτό το ταξίδι.',
'tours.library.failed': 'Δεν ήταν δυνατή η μεταφορά της διαδρομής.',
'tours.provenance.takenOverFrom': 'Μεταφέρθηκε από',
'tours.copy.action': 'Αποθήκευση ως νέα εκδρομή',
'tours.copy.hint':
'Αντιγράφει αυτή την εκδρομή ως νέα, ανεξάρτητη εκδρομή χωρίς τις μέρες της. Το πρωτότυπο μένει ως έχει.',
+24
View File
@@ -280,6 +280,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Idő az importokból',
'tours.scope.planned': 'Tervezett, szünetekkel',
'tours.scope.noMeasures': 'Még nem ismert távolság, magasság vagy idő.',
'tours.library.open': 'Túra átvétele másik útból',
'tours.library.eyebrow': 'Túra átvétele',
'tours.library.title': 'Másik útból',
'tours.library.sub': 'Átmásol egy túrát a többi utadból ebbe az útba. Az eredeti változatlan marad.',
'tours.library.search': 'Keresés név szerint',
'tours.library.trip': 'Út',
'tours.library.allTrips': 'Minden út',
'tours.library.allTypes': 'Minden típus',
'tours.library.empty': 'Nincs megfelelő túra.',
'tours.library.noTrips': 'A többi utadban még nincs túra.',
'tours.library.truncated': 'Az első 100 túra látszik. A többihez keress vagy szűrj.',
'tours.library.offline': 'A túra átvételéhez kapcsolat kell.',
'tours.library.loadFailed': 'A túrákat nem sikerült betölteni.',
'tours.library.previewFailed': 'Az előnézetet nem sikerült betölteni.',
'tours.library.chooseHint': 'Válassz egy túrát, hogy átvétel előtt itt megnézd.',
'tours.library.fromTrip': 'Útból',
'tours.library.controlPoints': 'Útpontok',
'tours.library.nameHere': 'Név ebben az útban',
'tours.library.independent':
'Ennek az útnak saját túrája lesz: még egyik napon sem, és a későbbi változtatások egyikben sem hatnak a másikra.',
'tours.library.confirm': 'Átvétel',
'tours.library.taken': '„{name}” átkerült ebbe az útba.',
'tours.library.failed': 'A túrát nem sikerült átvenni.',
'tours.provenance.takenOverFrom': 'Átvéve innen',
'tours.copy.action': 'Mentés új túraként',
'tours.copy.hint': 'Ezt a túrát új, önálló túraként másolja a napjai nélkül. Az eredeti változatlan marad.',
'tours.copy.name': '{name} (másolat)',
+24
View File
@@ -272,6 +272,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Waktu dari impor',
'tours.scope.planned': 'Rencana dengan istirahat',
'tours.scope.noMeasures': 'Belum ada jarak, ketinggian, atau waktu yang diketahui.',
'tours.library.open': 'Ambil tur dari perjalanan lain',
'tours.library.eyebrow': 'Ambil tur',
'tours.library.title': 'Dari perjalanan lain',
'tours.library.sub': 'Menyalin tur dari perjalananmu yang lain ke perjalanan ini. Aslinya tetap seperti semula.',
'tours.library.search': 'Cari berdasarkan nama',
'tours.library.trip': 'Perjalanan',
'tours.library.allTrips': 'Semua perjalanan',
'tours.library.allTypes': 'Semua jenis',
'tours.library.empty': 'Tidak ada tur yang cocok.',
'tours.library.noTrips': 'Perjalananmu yang lain belum punya tur.',
'tours.library.truncated': 'Menampilkan 100 tur pertama. Cari atau saring untuk menemukan yang lain.',
'tours.library.offline': 'Mengambil tur memerlukan koneksi.',
'tours.library.loadFailed': 'Tur tidak dapat dimuat.',
'tours.library.previewFailed': 'Pratinjau tidak dapat dimuat.',
'tours.library.chooseHint': 'Pilih tur untuk melihatnya di sini sebelum mengambilnya.',
'tours.library.fromTrip': 'Dari perjalanan',
'tours.library.controlPoints': 'Titik rute',
'tours.library.nameHere': 'Nama di perjalanan ini',
'tours.library.independent':
'Menjadi tur tersendiri di perjalanan ini: belum di hari mana pun, dan perubahan berikutnya pada salah satunya tetap terpisah.',
'tours.library.confirm': 'Ambil',
'tours.library.taken': '“{name}” diambil ke perjalanan ini.',
'tours.library.failed': 'Tur tidak dapat diambil.',
'tours.provenance.takenOverFrom': 'Diambil dari',
'tours.copy.action': 'Simpan sebagai tur baru',
'tours.copy.hint': 'Menyalin tur ini sebagai tur baru yang mandiri tanpa hari-harinya. Aslinya tetap seperti semula.',
'tours.copy.name': '{name} (salinan)',
+24
View File
@@ -280,6 +280,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Tempo dalle importazioni',
'tours.scope.planned': 'Pianificato con pause',
'tours.scope.noMeasures': 'Nessuna distanza, quota o tempo ancora noto.',
'tours.library.open': 'Riprendi un percorso da un altro viaggio',
'tours.library.eyebrow': 'Riprendi un percorso',
'tours.library.title': 'Da un altro viaggio',
'tours.library.sub': "Copia un percorso dei tuoi altri viaggi in questo viaggio. L'originale resta com'è.",
'tours.library.search': 'Cerca per nome',
'tours.library.trip': 'Viaggio',
'tours.library.allTrips': 'Tutti i viaggi',
'tours.library.allTypes': 'Tutti i tipi',
'tours.library.empty': 'Nessun percorso corrisponde.',
'tours.library.noTrips': 'I tuoi altri viaggi non hanno ancora percorsi.',
'tours.library.truncated': 'Sono mostrati i primi 100 percorsi. Cerca o filtra per trovare gli altri.',
'tours.library.offline': 'Per riprendere un percorso serve una connessione.',
'tours.library.loadFailed': 'Non è stato possibile caricare i percorsi.',
'tours.library.previewFailed': "Non è stato possibile caricare l'anteprima.",
'tours.library.chooseHint': 'Scegli un percorso per vederlo qui prima di riprenderlo.',
'tours.library.fromTrip': 'Dal viaggio',
'tours.library.controlPoints': 'Punti di passaggio',
'tours.library.nameHere': 'Nome in questo viaggio',
'tours.library.independent':
'Diventa un percorso a sé di questo viaggio: ancora in nessun giorno, e le modifiche successive a uno dei due restano separate.',
'tours.library.confirm': 'Riprendi',
'tours.library.taken': '“{name}” è stato ripreso in questo viaggio.',
'tours.library.failed': 'Non è stato possibile riprendere il percorso.',
'tours.provenance.takenOverFrom': 'Ripreso da',
'tours.copy.action': 'Salva come nuovo tour',
'tours.copy.hint': 'Copia questo tour come nuovo tour indipendente, senza i suoi giorni. L’originale resta com’è.',
'tours.copy.name': '{name} (copia)',
+24
View File
@@ -266,6 +266,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'インポートの時間',
'tours.scope.planned': '休憩込みの計画',
'tours.scope.noMeasures': '距離、高度、時間はまだわかりません。',
'tours.library.open': '別の旅からツアーを取り込む',
'tours.library.eyebrow': 'ツアーを取り込む',
'tours.library.title': '別の旅から',
'tours.library.sub': 'ほかの旅のツアーをこの旅にコピーします。元のツアーはそのままです。',
'tours.library.search': '名前で検索',
'tours.library.trip': '旅',
'tours.library.allTrips': 'すべての旅',
'tours.library.allTypes': 'すべての種類',
'tours.library.empty': '一致するツアーはありません。',
'tours.library.noTrips': 'ほかの旅にはまだツアーがありません。',
'tours.library.truncated': '最初の 100 件のツアーを表示しています。ほかは検索か絞り込みで探してください。',
'tours.library.offline': 'ツアーの取り込みには接続が必要です。',
'tours.library.loadFailed': 'ツアーを読み込めませんでした。',
'tours.library.previewFailed': 'プレビューを読み込めませんでした。',
'tours.library.chooseHint': '取り込む前にここで確認するツアーを選んでください。',
'tours.library.fromTrip': '元の旅',
'tours.library.controlPoints': '経由地',
'tours.library.nameHere': 'この旅での名前',
'tours.library.independent':
'この旅の独立したツアーになります。まだどの日にも入っておらず、以後の変更は互いに影響しません。',
'tours.library.confirm': '取り込む',
'tours.library.taken': '「{name}」をこの旅に取り込みました。',
'tours.library.failed': 'ツアーを取り込めませんでした。',
'tours.provenance.takenOverFrom': '取り込み元',
'tours.copy.action': '新しいツアーとして保存',
'tours.copy.hint': 'このツアーを日程なしの独立した新しいツアーとしてコピーします。元のツアーはそのままです。',
'tours.copy.name': '{name}(コピー)',
+24
View File
@@ -264,6 +264,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': '가져오기 시간',
'tours.scope.planned': '휴식 포함 계획',
'tours.scope.noMeasures': '아직 알려진 거리, 고도, 시간이 없습니다.',
'tours.library.open': '다른 여행에서 투어 가져오기',
'tours.library.eyebrow': '투어 가져오기',
'tours.library.title': '다른 여행에서',
'tours.library.sub': '다른 여행의 투어를 이 여행으로 복사합니다. 원본은 그대로 유지됩니다.',
'tours.library.search': '이름으로 검색',
'tours.library.trip': '여행',
'tours.library.allTrips': '모든 여행',
'tours.library.allTypes': '모든 유형',
'tours.library.empty': '일치하는 투어가 없습니다.',
'tours.library.noTrips': '다른 여행에는 아직 투어가 없습니다.',
'tours.library.truncated': '처음 100개 투어만 표시됩니다. 나머지는 검색하거나 필터로 찾으세요.',
'tours.library.offline': '투어를 가져오려면 연결이 필요합니다.',
'tours.library.loadFailed': '투어를 불러오지 못했습니다.',
'tours.library.previewFailed': '미리보기를 불러오지 못했습니다.',
'tours.library.chooseHint': '가져오기 전에 여기서 볼 투어를 선택하세요.',
'tours.library.fromTrip': '원래 여행',
'tours.library.controlPoints': '경유지',
'tours.library.nameHere': '이 여행에서의 이름',
'tours.library.independent':
'이 여행의 독립된 투어가 됩니다. 아직 어느 날에도 없으며, 이후 어느 쪽을 바꿔도 서로 영향을 주지 않습니다.',
'tours.library.confirm': '가져오기',
'tours.library.taken': '“{name}”을(를) 이 여행으로 가져왔습니다.',
'tours.library.failed': '투어를 가져오지 못했습니다.',
'tours.provenance.takenOverFrom': '가져온 곳',
'tours.copy.action': '새 투어로 저장',
'tours.copy.hint': '이 투어를 날짜 없이 독립된 새 투어로 복사합니다. 원본은 그대로입니다.',
'tours.copy.name': '{name} (사본)',
+24
View File
@@ -280,6 +280,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Tijd uit imports',
'tours.scope.planned': 'Gepland met pauzes',
'tours.scope.noMeasures': 'Nog geen afstand, hoogtes of tijden bekend.',
'tours.library.open': 'Tocht overnemen uit een andere reis',
'tours.library.eyebrow': 'Tocht overnemen',
'tours.library.title': 'Uit een andere reis',
'tours.library.sub': 'Kopieert een tocht uit je andere reizen naar deze reis. Het origineel blijft zoals het is.',
'tours.library.search': 'Zoeken op naam',
'tours.library.trip': 'Reis',
'tours.library.allTrips': 'Alle reizen',
'tours.library.allTypes': 'Alle soorten',
'tours.library.empty': 'Geen tocht past.',
'tours.library.noTrips': 'Je andere reizen hebben nog geen tochten.',
'tours.library.truncated': 'De eerste 100 tochten worden getoond. Zoek of filter om de andere te vinden.',
'tours.library.offline': 'Een tocht overnemen vraagt een verbinding.',
'tours.library.loadFailed': 'De tochten konden niet worden geladen.',
'tours.library.previewFailed': 'Het voorbeeld kon niet worden geladen.',
'tours.library.chooseHint': 'Kies een tocht om hem hier te bekijken voordat je hem overneemt.',
'tours.library.fromTrip': 'Uit de reis',
'tours.library.controlPoints': 'Routepunten',
'tours.library.nameHere': 'Naam in deze reis',
'tours.library.independent':
'Het wordt een eigen tocht van deze reis: nog op geen enkele dag, en latere wijzigingen aan een van beide blijven gescheiden.',
'tours.library.confirm': 'Overnemen',
'tours.library.taken': '‘{name}’ is overgenomen in deze reis.',
'tours.library.failed': 'De tocht kon niet worden overgenomen.',
'tours.provenance.takenOverFrom': 'Overgenomen uit',
'tours.copy.action': 'Opslaan als nieuwe tocht',
'tours.copy.hint':
'Kopieert deze tocht als nieuwe, zelfstandige tocht zonder zijn dagen. Het origineel blijft zoals het is.',
+24
View File
@@ -287,6 +287,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Czas z importów',
'tours.scope.planned': 'Plan z przerwami',
'tours.scope.noMeasures': 'Brak jeszcze znanego dystansu, wysokości i czasów.',
'tours.library.open': 'Przejmij trasę z innej podróży',
'tours.library.eyebrow': 'Przejmij trasę',
'tours.library.title': 'Z innej podróży',
'tours.library.sub': 'Kopiuje trasę z twoich innych podróży do tej podróży. Oryginał pozostaje bez zmian.',
'tours.library.search': 'Szukaj po nazwie',
'tours.library.trip': 'Podróż',
'tours.library.allTrips': 'Wszystkie podróże',
'tours.library.allTypes': 'Wszystkie typy',
'tours.library.empty': 'Żadna trasa nie pasuje.',
'tours.library.noTrips': 'Twoje inne podróże nie mają jeszcze tras.',
'tours.library.truncated': 'Pokazano pierwsze 100 tras. Wyszukaj lub przefiltruj, aby znaleźć pozostałe.',
'tours.library.offline': 'Przejęcie trasy wymaga połączenia.',
'tours.library.loadFailed': 'Nie udało się wczytać tras.',
'tours.library.previewFailed': 'Nie udało się wczytać podglądu.',
'tours.library.chooseHint': 'Wybierz trasę, aby zobaczyć ją tutaj przed przejęciem.',
'tours.library.fromTrip': 'Z podróży',
'tours.library.controlPoints': 'Punkty trasy',
'tours.library.nameHere': 'Nazwa w tej podróży',
'tours.library.independent':
'Staje się osobną trasą tej podróży: jeszcze bez dnia, a późniejsze zmiany w którejkolwiek pozostają oddzielne.',
'tours.library.confirm': 'Przejmij',
'tours.library.taken': '„{name}” przejęto do tej podróży.',
'tours.library.failed': 'Nie udało się przejąć trasy.',
'tours.provenance.takenOverFrom': 'Przejęto z',
'tours.copy.action': 'Zapisz jako nową trasę',
'tours.copy.hint': 'Kopiuje tę trasę jako nową, niezależną trasę bez jej dni. Oryginał zostaje bez zmian.',
'tours.copy.name': '{name} (kopia)',
+25
View File
@@ -289,6 +289,31 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Время из импорта',
'tours.scope.planned': 'План с перерывами',
'tours.scope.noMeasures': 'Расстояние, высоты и время пока неизвестны.',
'tours.library.open': 'Взять маршрут из другой поездки',
'tours.library.eyebrow': 'Взять маршрут',
'tours.library.title': 'Из другой поездки',
'tours.library.sub': 'Копирует маршрут из ваших других поездок в эту поездку. Оригинал остаётся без изменений.',
'tours.library.search': 'Поиск по названию',
'tours.library.trip': 'Поездка',
'tours.library.allTrips': 'Все поездки',
'tours.library.allTypes': 'Все типы',
'tours.library.empty': 'Подходящих маршрутов нет.',
'tours.library.noTrips': 'В ваших других поездках пока нет маршрутов.',
'tours.library.truncated':
'Показаны первые 100 маршрутов. Чтобы найти остальные, воспользуйтесь поиском или фильтром.',
'tours.library.offline': 'Чтобы взять маршрут, нужно подключение.',
'tours.library.loadFailed': 'Не удалось загрузить маршруты.',
'tours.library.previewFailed': 'Не удалось загрузить предпросмотр.',
'tours.library.chooseHint': 'Выберите маршрут, чтобы посмотреть его здесь, прежде чем взять.',
'tours.library.fromTrip': 'Из поездки',
'tours.library.controlPoints': 'Точки маршрута',
'tours.library.nameHere': 'Название в этой поездке',
'tours.library.independent':
'Он станет отдельным маршрутом этой поездки: пока ни в одном дне, а последующие изменения в любом из них останутся раздельными.',
'tours.library.confirm': 'Взять',
'tours.library.taken': '«{name}» добавлен в эту поездку.',
'tours.library.failed': 'Не удалось взять маршрут.',
'tours.provenance.takenOverFrom': 'Взят из',
'tours.copy.action': 'Сохранить как новый поход',
'tours.copy.hint': 'Копирует этот поход как новый, самостоятельный поход без его дней. Оригинал остаётся как есть.',
'tours.copy.name': '{name} (копия)',
+24
View File
@@ -274,6 +274,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Čas z importov',
'tours.scope.planned': 'Plán s prestávkami',
'tours.scope.noMeasures': 'Zatiaľ nie je známa vzdialenosť, výšky ani časy.',
'tours.library.open': 'Prevziať túru z inej cesty',
'tours.library.eyebrow': 'Prevziať túru',
'tours.library.title': 'Z inej cesty',
'tours.library.sub': 'Skopíruje túru z tvojich iných ciest do tejto cesty. Originál zostane bez zmeny.',
'tours.library.search': 'Hľadať podľa názvu',
'tours.library.trip': 'Cesta',
'tours.library.allTrips': 'Všetky cesty',
'tours.library.allTypes': 'Všetky typy',
'tours.library.empty': 'Žiadna túra nezodpovedá.',
'tours.library.noTrips': 'Tvoje iné cesty zatiaľ nemajú žiadne túry.',
'tours.library.truncated': 'Zobrazuje sa prvých 100 túr. Ostatné nájdeš hľadaním alebo filtrom.',
'tours.library.offline': 'Prevzatie túry potrebuje pripojenie.',
'tours.library.loadFailed': 'Túry sa nepodarilo načítať.',
'tours.library.previewFailed': 'Náhľad sa nepodarilo načítať.',
'tours.library.chooseHint': 'Vyber túru a pozri si ju tu, kým ju prevezmeš.',
'tours.library.fromTrip': 'Z cesty',
'tours.library.controlPoints': 'Body trasy',
'tours.library.nameHere': 'Názov v tejto ceste',
'tours.library.independent':
'Stane sa samostatnou túrou tejto cesty: zatiaľ v žiadnom dni a neskoršie zmeny jednej z nich zostanú oddelené.',
'tours.library.confirm': 'Prevziať',
'tours.library.taken': '„{name}“ bola prevzatá do tejto cesty.',
'tours.library.failed': 'Túru sa nepodarilo prevziať.',
'tours.provenance.takenOverFrom': 'Prevzaté z',
'tours.copy.action': 'Uložiť ako novú túru',
'tours.copy.hint': 'Skopíruje túto túru ako novú, samostatnú túru bez jej dní. Originál zostane, ako je.',
'tours.copy.name': '{name} (kópia)',
+24
View File
@@ -271,6 +271,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Tid från importer',
'tours.scope.planned': 'Planerat med pauser',
'tours.scope.noMeasures': 'Ingen sträcka, höjd eller tid känd ännu.',
'tours.library.open': 'Hämta en tur från en annan resa',
'tours.library.eyebrow': 'Hämta en tur',
'tours.library.title': 'Från en annan resa',
'tours.library.sub': 'Kopierar en tur från dina andra resor till den här resan. Originalet förblir som det är.',
'tours.library.search': 'Sök efter namn',
'tours.library.trip': 'Resa',
'tours.library.allTrips': 'Alla resor',
'tours.library.allTypes': 'Alla typer',
'tours.library.empty': 'Ingen tur matchar.',
'tours.library.noTrips': 'Dina andra resor har inga turer än.',
'tours.library.truncated': 'De första 100 turerna visas. Sök eller filtrera för att hitta de andra.',
'tours.library.offline': 'Att hämta en tur kräver en anslutning.',
'tours.library.loadFailed': 'Turerna kunde inte läsas in.',
'tours.library.previewFailed': 'Förhandsvisningen kunde inte läsas in.',
'tours.library.chooseHint': 'Välj en tur för att se den här innan du hämtar den.',
'tours.library.fromTrip': 'Från resan',
'tours.library.controlPoints': 'Vägpunkter',
'tours.library.nameHere': 'Namn i den här resan',
'tours.library.independent':
'Den blir en egen tur i den här resan: ännu inte på någon dag, och senare ändringar i någon av dem hålls isär.',
'tours.library.confirm': 'Hämta',
'tours.library.taken': '”{name}” hämtades till den här resan.',
'tours.library.failed': 'Turen kunde inte hämtas.',
'tours.provenance.takenOverFrom': 'Hämtad från',
'tours.copy.action': 'Spara som ny tur',
'tours.copy.hint': 'Kopierar turen som en ny, fristående tur utan dess dagar. Originalet förblir som det är.',
'tours.copy.name': '{name} (kopia)',
+23
View File
@@ -260,6 +260,29 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'เวลาจากการนำเข้า',
'tours.scope.planned': 'วางแผนรวมพัก',
'tours.scope.noMeasures': 'ยังไม่ทราบระยะทาง ความสูง หรือเวลา',
'tours.library.open': 'นำทัวร์จากทริปอื่นมาใช้',
'tours.library.eyebrow': 'นำทัวร์มาใช้',
'tours.library.title': 'จากทริปอื่น',
'tours.library.sub': 'คัดลอกทัวร์จากทริปอื่นของคุณมาไว้ในทริปนี้ ต้นฉบับยังคงเหมือนเดิม',
'tours.library.search': 'ค้นหาตามชื่อ',
'tours.library.trip': 'ทริป',
'tours.library.allTrips': 'ทุกทริป',
'tours.library.allTypes': 'ทุกประเภท',
'tours.library.empty': 'ไม่มีทัวร์ที่ตรงกัน',
'tours.library.noTrips': 'ทริปอื่นของคุณยังไม่มีทัวร์',
'tours.library.truncated': 'แสดง 100 ทัวร์แรก ค้นหาหรือกรองเพื่อหาทัวร์อื่น',
'tours.library.offline': 'การนำทัวร์มาใช้ต้องเชื่อมต่ออินเทอร์เน็ต',
'tours.library.loadFailed': 'โหลดทัวร์ไม่ได้',
'tours.library.previewFailed': 'โหลดตัวอย่างไม่ได้',
'tours.library.chooseHint': 'เลือกทัวร์เพื่อดูที่นี่ก่อนนำมาใช้',
'tours.library.fromTrip': 'จากทริป',
'tours.library.controlPoints': 'จุดแวะ',
'tours.library.nameHere': 'ชื่อในทริปนี้',
'tours.library.independent': 'กลายเป็นทัวร์ของทริปนี้เอง ยังไม่อยู่ในวันใด และการแก้ไขภายหลังของแต่ละอันจะแยกกัน',
'tours.library.confirm': 'นำมาใช้',
'tours.library.taken': 'นำ “{name}” มาใช้ในทริปนี้แล้ว',
'tours.library.failed': 'นำทัวร์มาใช้ไม่ได้',
'tours.provenance.takenOverFrom': 'นำมาจาก',
'tours.copy.action': 'บันทึกเป็นทัวร์ใหม่',
'tours.copy.hint': 'คัดลอกทัวร์นี้เป็นทัวร์ใหม่ที่แยกเป็นอิสระโดยไม่รวมวัน ต้นฉบับจะคงเดิม',
'tours.copy.name': '{name} (สำเนา)',
+2 -2
View File
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
const GENERAL_KEY_COUNT = 574;
const GENERAL_KEY_COUNT = 597;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
@@ -34,7 +34,7 @@ function literalKeys(source: string): string[] {
}
describe('Tours locale contracts', () => {
it('covers all 27 supported locales with 574 general source keys', () => {
it('covers all 27 supported locales with 597 general source keys', () => {
expect(Object.keys(domains)).toHaveLength(27);
expect(Object.keys(domains).sort()).toEqual(SUPPORTED_LANGUAGE_CODES.map((code) => `./${code}/tours.ts`).sort());
expect(generalKeys(Object.keys(english))).toHaveLength(GENERAL_KEY_COUNT);
+24
View File
@@ -273,6 +273,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'İçe aktarmalardan süre',
'tours.scope.planned': 'Molalarla planlanan',
'tours.scope.noMeasures': 'Henüz bilinen mesafe, yükseklik veya süre yok.',
'tours.library.open': 'Başka bir geziden tur al',
'tours.library.eyebrow': 'Tur al',
'tours.library.title': 'Başka bir geziden',
'tours.library.sub': 'Diğer gezilerindeki bir turu bu geziye kopyalar. Orijinal olduğu gibi kalır.',
'tours.library.search': 'Ada göre ara',
'tours.library.trip': 'Gezi',
'tours.library.allTrips': 'Tüm geziler',
'tours.library.allTypes': 'Tüm türler',
'tours.library.empty': 'Eşleşen tur yok.',
'tours.library.noTrips': 'Diğer gezilerinde henüz tur yok.',
'tours.library.truncated': 'İlk 100 tur gösteriliyor. Diğerlerini bulmak için ara ya da filtrele.',
'tours.library.offline': 'Tur almak için bağlantı gerekir.',
'tours.library.loadFailed': 'Turlar yüklenemedi.',
'tours.library.previewFailed': 'Önizleme yüklenemedi.',
'tours.library.chooseHint': 'Almadan önce burada görmek için bir tur seç.',
'tours.library.fromTrip': 'Geziden',
'tours.library.controlPoints': 'Ara noktalar',
'tours.library.nameHere': 'Bu gezideki ad',
'tours.library.independent':
'Bu gezinin kendi turu olur: henüz hiçbir günde değil, sonraki değişiklikler ikisinde de ayrı kalır.',
'tours.library.confirm': 'Al',
'tours.library.taken': '“{name}” bu geziye alındı.',
'tours.library.failed': 'Tur alınamadı.',
'tours.provenance.takenOverFrom': 'Alındığı yer',
'tours.copy.action': 'Yeni tur olarak kaydet',
'tours.copy.hint': 'Bu turu günleri olmadan yeni ve bağımsız bir tur olarak kopyalar. Asıl tur olduğu gibi kalır.',
'tours.copy.name': '{name} (kopya)',
+24
View File
@@ -286,6 +286,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Час з імпорту',
'tours.scope.planned': 'План з перервами',
'tours.scope.noMeasures': 'Відстань, висоти й час поки невідомі.',
'tours.library.open': 'Взяти маршрут з іншої подорожі',
'tours.library.eyebrow': 'Взяти маршрут',
'tours.library.title': 'З іншої подорожі',
'tours.library.sub': 'Копіює маршрут з ваших інших подорожей у цю подорож. Оригінал лишається без змін.',
'tours.library.search': 'Пошук за назвою',
'tours.library.trip': 'Подорож',
'tours.library.allTrips': 'Усі подорожі',
'tours.library.allTypes': 'Усі типи',
'tours.library.empty': 'Відповідних маршрутів немає.',
'tours.library.noTrips': 'У ваших інших подорожах ще немає маршрутів.',
'tours.library.truncated': 'Показано перші 100 маршрутів. Щоб знайти інші, скористайтеся пошуком або фільтром.',
'tours.library.offline': "Щоб взяти маршрут, потрібне з'єднання.",
'tours.library.loadFailed': 'Не вдалося завантажити маршрути.',
'tours.library.previewFailed': 'Не вдалося завантажити попередній перегляд.',
'tours.library.chooseHint': 'Виберіть маршрут, щоб переглянути його тут, перш ніж узяти.',
'tours.library.fromTrip': 'З подорожі',
'tours.library.controlPoints': 'Точки маршруту',
'tours.library.nameHere': 'Назва в цій подорожі',
'tours.library.independent':
'Він стане окремим маршрутом цієї подорожі: поки що в жодному дні, а подальші зміни в будь-якому з них лишаться окремими.',
'tours.library.confirm': 'Взяти',
'tours.library.taken': '«{name}» додано в цю подорож.',
'tours.library.failed': 'Не вдалося взяти маршрут.',
'tours.provenance.takenOverFrom': 'Взято з',
'tours.copy.action': 'Зберегти як новий похід',
'tours.copy.hint': 'Копіює цей похід як новий, самостійний похід без його днів. Оригінал лишається як є.',
'tours.copy.name': '{name} (копія)',
+24
View File
@@ -273,6 +273,30 @@ const tours: TranslationStrings = {
'tours.scope.imported': 'Thời gian từ bản nhập',
'tours.scope.planned': 'Kế hoạch gồm nghỉ',
'tours.scope.noMeasures': 'Chưa biết quãng đường, độ cao hay thời gian.',
'tours.library.open': 'Lấy chuyến từ chuyến đi khác',
'tours.library.eyebrow': 'Lấy chuyến',
'tours.library.title': 'Từ chuyến đi khác',
'tours.library.sub': 'Sao chép một chuyến từ các chuyến đi khác của bạn vào chuyến đi này. Bản gốc vẫn giữ nguyên.',
'tours.library.search': 'Tìm theo tên',
'tours.library.trip': 'Chuyến đi',
'tours.library.allTrips': 'Tất cả chuyến đi',
'tours.library.allTypes': 'Tất cả loại',
'tours.library.empty': 'Không có chuyến nào khớp.',
'tours.library.noTrips': 'Các chuyến đi khác của bạn chưa có chuyến nào.',
'tours.library.truncated': 'Đang hiện 100 chuyến đầu tiên. Hãy tìm hoặc lọc để thấy các chuyến khác.',
'tours.library.offline': 'Lấy chuyến cần có kết nối mạng.',
'tours.library.loadFailed': 'Không tải được các chuyến.',
'tours.library.previewFailed': 'Không tải được bản xem trước.',
'tours.library.chooseHint': 'Chọn một chuyến để xem ở đây trước khi lấy.',
'tours.library.fromTrip': 'Từ chuyến đi',
'tours.library.controlPoints': 'Điểm tham chiếu',
'tours.library.nameHere': 'Tên trong chuyến đi này',
'tours.library.independent':
'Nó trở thành chuyến riêng của chuyến đi này: chưa thuộc ngày nào, và thay đổi sau này ở bên nào cũng tách biệt.',
'tours.library.confirm': 'Lấy',
'tours.library.taken': 'Đã lấy “{name}” vào chuyến đi này.',
'tours.library.failed': 'Không lấy được chuyến.',
'tours.provenance.takenOverFrom': 'Lấy từ',
'tours.copy.action': 'Lưu thành chuyến đi bộ mới',
'tours.copy.hint': 'Sao chép chuyến này thành một chuyến đi bộ mới, độc lập, không kèm các ngày. Bản gốc giữ nguyên.',
'tours.copy.name': '{name} (bản sao)',
+23
View File
@@ -250,6 +250,29 @@ const tours: TranslationStrings = {
'tours.scope.imported': '匯入時間',
'tours.scope.planned': '含休息的計畫',
'tours.scope.noMeasures': '尚無已知的距離、高度或時間。',
'tours.library.open': '從其他行程引入路線',
'tours.library.eyebrow': '引入路線',
'tours.library.title': '來自其他行程',
'tours.library.sub': '將你其他行程中的路線複製到本行程。原路線保持不變。',
'tours.library.search': '依名稱搜尋',
'tours.library.trip': '行程',
'tours.library.allTrips': '所有行程',
'tours.library.allTypes': '所有類型',
'tours.library.empty': '沒有符合的路線。',
'tours.library.noTrips': '你的其他行程還沒有路線。',
'tours.library.truncated': '僅顯示前 100 條路線。請搜尋或篩選以找到其餘路線。',
'tours.library.offline': '引入路線需要網路連線。',
'tours.library.loadFailed': '無法載入路線。',
'tours.library.previewFailed': '無法載入預覽。',
'tours.library.chooseHint': '選擇一條路線,在引入前於此查看。',
'tours.library.fromTrip': '來自行程',
'tours.library.controlPoints': '途經點',
'tours.library.nameHere': '在本行程中的名稱',
'tours.library.independent': '它會成為本行程中獨立的路線:尚未安排到任何一天,之後對任一方的修改互不影響。',
'tours.library.confirm': '引入',
'tours.library.taken': '已將「{name}」引入本行程。',
'tours.library.failed': '無法引入路線。',
'tours.provenance.takenOverFrom': '引入自',
'tours.copy.action': '另存為新遊程',
'tours.copy.hint': '將此遊程複製為不含日程的獨立新遊程。原遊程保持不變。',
'tours.copy.name': '{name}(副本)',
+23
View File
@@ -251,6 +251,29 @@ const tours: TranslationStrings = {
'tours.scope.imported': '导入时间',
'tours.scope.planned': '含休息的计划',
'tours.scope.noMeasures': '尚无已知的距离、高度或时间。',
'tours.library.open': '从其他行程引入路线',
'tours.library.eyebrow': '引入路线',
'tours.library.title': '来自其他行程',
'tours.library.sub': '将你其他行程中的路线复制到本行程。原路线保持不变。',
'tours.library.search': '按名称搜索',
'tours.library.trip': '行程',
'tours.library.allTrips': '所有行程',
'tours.library.allTypes': '所有类型',
'tours.library.empty': '没有匹配的路线。',
'tours.library.noTrips': '你的其他行程还没有路线。',
'tours.library.truncated': '仅显示前 100 条路线。请搜索或筛选以找到其余路线。',
'tours.library.offline': '引入路线需要网络连接。',
'tours.library.loadFailed': '无法加载路线。',
'tours.library.previewFailed': '无法加载预览。',
'tours.library.chooseHint': '选择一条路线,在引入前于此查看。',
'tours.library.fromTrip': '来自行程',
'tours.library.controlPoints': '途经点',
'tours.library.nameHere': '在本行程中的名称',
'tours.library.independent': '它会成为本行程中独立的路线:尚未安排到任何一天,之后对任一方的修改互不影响。',
'tours.library.confirm': '引入',
'tours.library.taken': '已将“{name}”引入本行程。',
'tours.library.failed': '无法引入路线。',
'tours.provenance.takenOverFrom': '引入自',
'tours.copy.action': '另存为新路线',
'tours.copy.hint': '将此路线复制为不含日程的独立新路线。原路线保持不变。',
'tours.copy.name': '{name}(副本)',