diff --git a/client/src/api/tourLibrary.ts b/client/src/api/tourLibrary.ts new file mode 100644 index 000000000..bc29a5c0a --- /dev/null +++ b/client/src/api/tourLibrary.ts @@ -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 => + apiClient.get('/tours/library', { params: query, signal }).then((r) => r.data), + detail: (placeId: number, signal?: AbortSignal): Promise => + apiClient.get(`/tours/library/${placeId}`, { signal }).then((r) => r.data), + takeOver: (tripId: number | string, body: TourTakeOverRequest): Promise => + apiClient.post(`/trips/${tripId}/tours/take-over`, body).then((r) => r.data), +}; diff --git a/client/src/components/Tours/library/TourLibraryDialog.tsx b/client/src/components/Tours/library/TourLibraryDialog.tsx new file mode 100644 index 000000000..58030fed7 --- /dev/null +++ b/client/src/components/Tours/library/TourLibraryDialog.tsx @@ -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; +type Unit = Parameters[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 ( +
  • + +
  • + ); +} + +/** 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 }>{t('tours.library.offline')}; + if (listStatus === 'error') + return ( + }> + + {t('tours.library.loadFailed')} + + + + ); + if (listStatus === 'loading' && !list) + return ( +

    + + {t('common.loading')} +

    + ); + if (list && list.tours.length === 0) + return ( +

    + {list.trips.length === 0 ? t('tours.library.noTrips') : t('tours.library.empty')} +

    + ); + if (list?.truncated) return {t('tours.library.truncated')}; + 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 ( +
    +
    + + + 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')} + /> +
    +
    + library.filter({ tripFilter: value === ALL ? null : Number(value) })} + /> + library.filter({ typeFilter: value === ALL ? null : (value as TourTypeKey) })} + /> +
    +
    + ); +} + +/** 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 ( +
    +

    + {t('tours.library.chooseHint')} +

    +
    + ); + return ( +
    +
    + {route.length > 0 && ( + + )} + {library.previewStatus === 'loading' && ( + + + + )} + {(library.previewStatus === 'error' || library.previewStatus === 'offline') && ( + + {t(library.previewStatus === 'offline' ? 'tours.library.offline' : 'tours.library.previewFailed')} + + )} +
    + + + library.setName(event.target.value)} + /> + +
    + ); +} + +/** + * 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 ( + + + + } + 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={ + + + + {t('common.cancel')} + + + ) : ( + + ) + } + disabled={!library.canConfirm} + onClick={() => void library.confirm()} + > + {t('tours.library.confirm')} + + + } + > +
    +
    + + + {library.list && library.list.tours.length > 0 && ( +
      + {library.list.tours.map((tour) => ( + library.choose(tour)} + /> + ))} +
    + )} +
    + +
    +
    + ); +} + +/** 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 ( + <> + + + + + + ); +} + +/** + * 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; + /** Where the tour goes into the trip; without it there is no way in. */ + onTakenOver?: (tour: TourListItem) => Promise; + disabled?: boolean; +}) { + const library = useTourLibrary({ + tripId: Number(planner.tripId), + onTakenOver: async (tour) => { + await onTakenOver?.(tour); + if (!planner.hasUnsavedChanges) void planner.openTour(tour); + }, + }); + return onTakenOver ? : null; +} diff --git a/client/src/components/Tours/library/tourLibrary.test.tsx b/client/src/components/Tours/library/tourLibrary.test.tsx new file mode 100644 index 000000000..7e5493371 --- /dev/null +++ b/client/src/components/Tours/library/tourLibrary.test.tsx @@ -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> }) => ( +
    + ), +})); + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock('../../shared/Toast', () => ({ useToast: () => toast })); + +function item(over: Partial & Pick): 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; +let detail: ReturnType; +let takeOver: ReturnType; + +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 ; + } + + it('FE-TOUR-LIBRARY-006: lists the tours with their trip and measures, a missing one left out', async () => { + render(); + 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(); + 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(); + fireEvent.click(screen.getByTestId('tour-rail-library')); + expect(await screen.findByText('Your other trips have no tours yet.')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Tours/library/useTourLibrary.ts b/client/src/components/Tours/library/useTourLibrary.ts new file mode 100644 index 000000000..c98867dec --- /dev/null +++ b/client/src/components/Tours/library/useTourLibrary.ts @@ -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 { + key: string; + status: Exclude; + 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 | 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 | 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; +}) { + const { t } = useTranslation(); + const toast = useToast(); + const [open, setOpen] = useState(false); + const [filters, setFilters] = useState({ query: '', tripFilter: null, typeFilter: null }); + const [selected, setSelected] = useState(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) => 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; diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index 4d25e5386..0b1930f5c 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -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> = { 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 } /** 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
    {canEdit && gpxImport && } + {canEdit && } {canEdit && planner.mode.type !== 'neutral' && ( { if (!isSaving) planner.startNewTour() }} diff --git a/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx b/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx index 34b48dbc8..447dd79a4 100644 --- a/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx +++ b/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx @@ -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( + + ); + expect(screen.getByText('tours.provenance.takenOverFrom')).toBeInTheDocument(); + expect(screen.getByTestId('tour-provenance-taken-over')).toHaveTextContent('Alps: Ridge'); + rerender(); + 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( + {provenance.takenOverFrom && ( + // LIB-02: the trip and tour it was taken from, as they were called then. + } label={t('tours.provenance.takenOverFrom')}> + + {provenance.takenOverFrom.tripTitle} + {': '} + {provenance.takenOverFrom.tourName} + + + )} {importedAt && ( } diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 104288341..ec2d35368 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -687,6 +687,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onDeleteTour={handleDeleteTour} gpxImport={tourGpxImport} onViewGpxTour={viewImportedTour} + onTakenOver={tourDetailActions.onTakenOver} /> )} diff --git a/client/src/pages/tripPlanner/useTourDetailActions.ts b/client/src/pages/tripPlanner/useTourDetailActions.ts index e2481cca0..7b7e9ce39 100644 --- a/client/src/pages/tripPlanner/useTourDetailActions.ts +++ b/client/src/pages/tripPlanner/useTourDetailActions.ts @@ -13,11 +13,15 @@ import type { useTripPlanner } from './useTripPlanner'; export function useTourDetailActions(planner: ReturnType) { 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) 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); diff --git a/client/src/repo/tourLibraryRepo.test.ts b/client/src/repo/tourLibraryRepo.test.ts new file mode 100644 index 000000000..b8d747ab9 --- /dev/null +++ b/client/src/repo/tourLibraryRepo.test.ts @@ -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); + }); +}); diff --git a/client/src/repo/tourLibraryRepo.ts b/client/src/repo/tourLibraryRepo.ts new file mode 100644 index 000000000..01503b315 --- /dev/null +++ b/client/src/repo/tourLibraryRepo.ts @@ -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 { + requireOnline(); + return await tourLibraryApi.list(query, signal); + }, + + async detail(placeId: number, signal?: AbortSignal): Promise { + requireOnline(); + return await tourLibraryApi.detail(placeId, signal); + }, + + async takeOver(tripId: number | string, body: TourTakeOverRequest): Promise { + requireOnline(); + const result = await tourLibraryApi.takeOver(tripId, body); + await cacheTourAnswer(Number(tripId), result); + return result; + }, +}; diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index 99c444d32..b6934e630 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -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} (نسخة)', diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index de60c3c4e..450e319e6 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -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)', diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index 4ca4f1d72..8dcb0c5fd 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -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á.', diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index c0fd68792..97eb05911 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -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à.', diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 69e0dcba7..f6b09aa7c 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -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)', diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index 156dbfb2b..e020b5a11 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -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)', diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index 27bcb31d7..d0f56fd91 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -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)', diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index ddbcfbfb9..baed11aa3 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -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á.', diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index ac795adbf..ddb1ca8fa 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -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)', diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index 4cdee36bd..b53dfe521 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -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.', diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index 977fe4040..ae5985409 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -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': 'Αντιγράφει αυτή την εκδρομή ως νέα, ανεξάρτητη εκδρομή χωρίς τις μέρες της. Το πρωτότυπο μένει ως έχει.', diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index 593bd33b6..87cf3f181 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -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)', diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index e13c015c4..4e6052274 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -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)', diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index ce7b0c1d9..7dd79f97c 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -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)', diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 33ef21643..843fafc11 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -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}(コピー)', diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index 22b94368b..be21ba94d 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -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} (사본)', diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index f175d7cf5..2c3209cf9 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -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.', diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index b4820a08e..c7c390c18 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -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)', diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index 4873b61a9..fcae3a7f0 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -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} (копия)', diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index a7b50ee58..0159b14a7 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -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)', diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index a04c44350..4ef118ecb 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -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)', diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index 3a038277f..d1bbfd784 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -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} (สำเนา)', diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index 01ecd0816..b95d01674 100644 --- a/shared/src/i18n/tours-locales.spec.ts +++ b/shared/src/i18n/tours-locales.spec.ts @@ -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); diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index bff7f61bc..8f08c877d 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -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)', diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 2fefc8bff..f44e223f7 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -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} (копія)', diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index d16fc9d4f..1a64f11c9 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -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)', diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index cf33d3778..7d9e5590e 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -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}(副本)', diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index 83ac67825..5e93446a4 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -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}(副本)',