mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Tours: save a tour as a new, independent tour (P2C-07)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import type { TourCopyRequest, TourCreateResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** P2C-07: a tour of the trip saved again as a new, independent tour. */
|
||||
export const tourCopiesApi = {
|
||||
copy: (tripId: number | string, placeId: number | string, body: TourCopyRequest): Promise<TourCreateResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/${placeId}/copy`, body).then((r) => r.data),
|
||||
};
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
CopyPlus,
|
||||
ExternalLink,
|
||||
File,
|
||||
FileDown,
|
||||
FileImage,
|
||||
FileText,
|
||||
Loader2,
|
||||
Minus,
|
||||
Mountain,
|
||||
Navigation,
|
||||
@@ -72,6 +74,8 @@ interface TourDetailDialogProps extends TourPermissionProps {
|
||||
onFileUpload?: (formData: FormData) => Promise<unknown>;
|
||||
/** A wanderer snapshot's source check brought the changed trail in as a new tour (MF-03). */
|
||||
onSourceReimported?: (tour: TourListItem) => Promise<void> | void;
|
||||
/** P2C-07: save this tour again as a new, independent one. */
|
||||
onSaveAsNew?: (tour: TourListItem) => Promise<void> | void;
|
||||
onAssignToDay?: (placeId: number, dayId?: number) => void;
|
||||
onRemoveAssignment?: (dayId: number, assignmentId: number) => void;
|
||||
onDelete?: () => void;
|
||||
@@ -303,6 +307,7 @@ export default function TourDetailDialog({
|
||||
onUpdatePlace,
|
||||
onFileUpload,
|
||||
onSourceReimported,
|
||||
onSaveAsNew,
|
||||
onAssignToDay,
|
||||
onRemoveAssignment,
|
||||
onDelete,
|
||||
@@ -506,6 +511,7 @@ export default function TourDetailDialog({
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit && onSaveAsNew && <SaveAsNewButton onSave={() => onSaveAsNew(tour)} />}
|
||||
<FooterSpacer />
|
||||
{canEdit && onDelete && (
|
||||
<DeleteButton
|
||||
@@ -593,6 +599,35 @@ export default function TourDetailDialog({
|
||||
);
|
||||
}
|
||||
|
||||
/** P2C-07 in the footer: one press, the copy opens; the button waits while the server makes it. */
|
||||
function SaveAsNewButton({ onSave }: { onSave: () => Promise<void> | void }) {
|
||||
const { t } = useTranslation();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const save = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Tooltip label={t('tours.copy.hint')}>
|
||||
<span className="inline-flex">
|
||||
<DialogButton
|
||||
onClick={() => void save()}
|
||||
disabled={saving}
|
||||
icon={saving ? <Loader2 size={14} className="animate-spin" /> : <CopyPlus size={14} strokeWidth={2} />}
|
||||
aria-label={t('tours.copy.action')}
|
||||
>
|
||||
<span className="max-sm:hidden">{t('tours.copy.action')}</span>
|
||||
</DialogButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A tour's files: the count to unfold, each file to open, and an upload for who may change the tour. */
|
||||
function TourFilesSection({
|
||||
placeId,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner';
|
||||
import { useRecordedTrack } from '../../components/Tours/useRecordedTrack';
|
||||
import { useTourGpxImport } from '../../components/Tours/useTourGpxImport';
|
||||
import { useWandererBrowser } from '../../components/Tours/wanderer/useWandererBrowser';
|
||||
import { tourRepo } from '../../repo/tourRepo';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Place } from '../../types';
|
||||
@@ -130,6 +131,23 @@ export function useTourWorkspace(
|
||||
void invalidateTourPlaceIds({ placeIds: [tour.place_id] });
|
||||
planner.handlePlaceClick(tour.place_id);
|
||||
},
|
||||
// P2C-07: the copy opens at once, under a name that says it is one; renaming it is the next step.
|
||||
onSaveAsNew: async (tour: TourListItem) => {
|
||||
try {
|
||||
const { tour: copy } = await tourRepo.copy(
|
||||
tripId,
|
||||
tour.place_id,
|
||||
t('tours.copy.name', { name: tour.name }).slice(0, 255)
|
||||
);
|
||||
upsertTour(copy);
|
||||
await useTripStore.getState().refreshPlaces(tripId);
|
||||
void invalidateTourPlaceIds({ placeIds: [copy.place_id] });
|
||||
planner.handlePlaceClick(copy.place_id);
|
||||
toast.success(t('tours.copy.saved'));
|
||||
} catch {
|
||||
toast.error(t('tours.copy.failed'));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computeTourMetrics, type GeometryPoint, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint } from '@trek/shared'
|
||||
import { toursApi } from '../api/client'
|
||||
import { recordedTrackApi } from '../api/recordedTrack'
|
||||
import { tourCopiesApi } from '../api/tourCopies'
|
||||
import { offlineDb, replaceTripTours, type CachedTour } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
@@ -189,6 +190,16 @@ export const tourRepo = {
|
||||
return result
|
||||
},
|
||||
|
||||
/**
|
||||
* P2C-07, online only like the restore: the copy is made from the server's
|
||||
* stored line and control points, so it needs the server to answer.
|
||||
*/
|
||||
async copy(tripId: number | string, placeId: number, name: string): Promise<TourCreateResponse> {
|
||||
const result = await tourCopiesApi.copy(tripId, placeId, { name })
|
||||
await offlineDb.tours.put({ ...result.tour, trip_id: Number(tripId), waypoints: result.waypoints })
|
||||
return result
|
||||
},
|
||||
|
||||
async importGpx(tripId: number | string, file: File): Promise<TourImportGpxResponse> {
|
||||
const result = await toursApi.importGpx(tripId, file)
|
||||
await offlineDb.tours.bulkPut(result.tours.map(tour => ({ ...tour, trip_id: Number(tripId) })))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { tourCreateRequestSchema } from '@trek/shared';
|
||||
import { tourCopyRequestSchema, tourCreateRequestSchema } from '@trek/shared';
|
||||
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
export class TourCreateDto extends createZodDto(tourCreateRequestSchema) {}
|
||||
export class TourCopyDto extends createZodDto(tourCopyRequestSchema) {}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { TourCreateDto } from './dto/tour-create.dto';
|
||||
import { TourCopyDto, TourCreateDto } from './dto/tour-create.dto';
|
||||
import { ToursService } from './tours.service';
|
||||
import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { tourDetailResponseSchema } from '@trek/shared';
|
||||
import { tourCreateResponseSchema, tourDetailResponseSchema } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* /api/trips/:tripId/tours: the Tours facet, read and route edits.
|
||||
@@ -53,6 +53,19 @@ export class ToursController {
|
||||
return await this.tours.updateTour(tripId, placeId, body, socketId);
|
||||
}
|
||||
|
||||
/** P2C-07: the tour saved again as a new, independent tour of the trip, without its days. */
|
||||
@RequirePermission('place_edit')
|
||||
@Post(':placeId/copy')
|
||||
@ResponseContract(tourCreateResponseSchema)
|
||||
async copy(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('placeId') placeId: string,
|
||||
@Body() body: TourCopyDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return await this.tours.copyTour(tripId, placeId, body.name, socketId);
|
||||
}
|
||||
|
||||
/** Takes an import planned over in place back to its recorded track; the planning is dropped. */
|
||||
@RequirePermission('place_edit')
|
||||
@Post(':placeId/restore-recorded')
|
||||
|
||||
@@ -336,6 +336,67 @@ export class ToursService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* P2C-07 "save as new tour": an independent tour made from one of the trip,
|
||||
* with its own id, its own copy of the line and the control points, the
|
||||
* same measures and source, and none of the days. It points at the tour it
|
||||
* came from (`derived_from_place_id`), so the detail can name it; nothing
|
||||
* keeps the two in step afterwards.
|
||||
*/
|
||||
async copyTour(tripId: string, placeId: string, name: string, socketId?: string): Promise<TourCreateResponse> {
|
||||
const tid = toRowId(tripId);
|
||||
const pid = toRowId(placeId);
|
||||
if (tid === null || pid === null) throw new NotFoundException('Tour not found');
|
||||
|
||||
const copyId = await this.uow.transactional(async () => {
|
||||
const [source, provenance, place] = await Promise.all([
|
||||
this.toursRepo.findInTrip(tid, pid),
|
||||
this.toursRepo.findProvenance(tid, pid),
|
||||
this.placesRepo.findInTrip(pid, tid),
|
||||
]);
|
||||
if (!source || !provenance || !place) throw new NotFoundException('Tour not found');
|
||||
const start = parseRouteGeometry(place.route_geometry)[0];
|
||||
const id = await this.placesRepo.insertTourPlace({
|
||||
trip_id: tid,
|
||||
name,
|
||||
lat: start?.[0] ?? place.lat ?? 0,
|
||||
lng: start?.[1] ?? place.lng ?? 0,
|
||||
route_geometry: place.route_geometry,
|
||||
description: place.description ?? null,
|
||||
website: place.website ?? null,
|
||||
});
|
||||
await this.toursRepo.insertTour({
|
||||
place_id: id,
|
||||
tour_type: source.tour_type,
|
||||
distance: source.distance,
|
||||
elevation_gain: source.elevation_gain,
|
||||
elevation_loss: source.elevation_loss,
|
||||
duration: source.duration,
|
||||
planned_duration_minutes: source.planned_duration_minutes,
|
||||
break_additional_minutes: source.break_additional_minutes,
|
||||
match_confidence: source.match_confidence,
|
||||
max_hiking_difficulty: source.max_hiking_difficulty,
|
||||
wanderer_ref: source.wanderer_ref,
|
||||
difficulty: source.difficulty,
|
||||
derived_from_place_id: pid,
|
||||
origin: source.origin as TourOrigin,
|
||||
origin_file_name: provenance.origin_file_name,
|
||||
});
|
||||
await this.waypointsRepo.insertForPlace(id, await this.waypointsRepo.listForPlace(pid));
|
||||
return id;
|
||||
});
|
||||
|
||||
const result = await this.getTour(tripId, String(copyId));
|
||||
const created = await this.placesRepo.findWithTagsAndRatings(copyId);
|
||||
this.broadcastToursChanged(tripId, [copyId], socketId);
|
||||
try {
|
||||
if (created) this.places.broadcast(tripId, 'place:created', { place: created }, socketId);
|
||||
} catch {
|
||||
this.logger.warn(`Committed Tour copy ${copyId}: place notification failed`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Replace a saved tour's derived route and routing controls as one write. */
|
||||
async updateTour(
|
||||
tripId: string,
|
||||
|
||||
@@ -548,6 +548,66 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('save as new tour (P2C-07)', () => {
|
||||
const copy = (
|
||||
placeId: number,
|
||||
body: Record<string, unknown> = { name: 'Ridge (copy)' },
|
||||
userId = OWNER,
|
||||
tripId = 5,
|
||||
) =>
|
||||
request(server)
|
||||
.post(`/api/trips/${tripId}/tours/${placeId}/copy`)
|
||||
.set('Cookie', sessionCookie(userId))
|
||||
.send(body);
|
||||
const detail = async (placeId: number) =>
|
||||
(await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER))).body;
|
||||
|
||||
it('makes an independent tour with its own line and control points, no days, pointing at its source', async () => {
|
||||
const source = await createViaApi();
|
||||
await makeDayAssignment(orm, 3, source, { order_index: 0 });
|
||||
const res = await copy(source);
|
||||
expect(res.status).toBe(201);
|
||||
const copyId = res.body.tour.place_id as number;
|
||||
expect(copyId).not.toBe(source);
|
||||
expect(res.body.tour).toMatchObject({
|
||||
name: 'Ridge (copy)',
|
||||
derived_from_place_id: source,
|
||||
planned: false,
|
||||
origin: 'trek',
|
||||
});
|
||||
expect(res.body.waypoints).toEqual((await detail(source)).waypoints);
|
||||
expect(res.body.provenance).toMatchObject({ derivedFrom: { placeId: source } });
|
||||
expect((await placeColumns(copyId, 'route_geometry')).route_geometry).toBe(
|
||||
(await placeColumns(source, 'route_geometry')).route_geometry,
|
||||
);
|
||||
|
||||
// Editing the copy leaves the original as it was.
|
||||
await request(server)
|
||||
.put(`/api/trips/5/tours/${copyId}`)
|
||||
.set('Cookie', sessionCookie(OWNER))
|
||||
.send(tourBody({ name: 'Shorter ridge' }));
|
||||
expect((await detail(source)).tour).toMatchObject({ name: tourBody().name, planned: true });
|
||||
});
|
||||
|
||||
it('a copied GPX import stays a GPX import with its file name', async () => {
|
||||
const imported = await importGpx(OWNER, TRACK_GPX);
|
||||
const source = imported.body.tours[0].place_id as number;
|
||||
const res = await copy(source, { name: 'Track again' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.tour).toMatchObject({ origin: 'gpx', has_waypoints: false });
|
||||
expect(res.body.provenance).toMatchObject({ source: { kind: 'gpx', fileName: 'track.gpx' } });
|
||||
});
|
||||
|
||||
it('refuses an empty name, a tour of another trip, a plain place, and someone who may not add places', async () => {
|
||||
const source = await createViaApi();
|
||||
expect((await copy(source, { name: ' ' })).status).toBe(400);
|
||||
expect((await copy(source, { name: 'x' }, VIEWER)).status).toBe(403);
|
||||
expect((await copy(await plainPlace(5, 'Cafe'))).status).toBe(404);
|
||||
const foreign = await createViaApi(6);
|
||||
expect((await copy(foreign)).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planning over an import (PR1-05)', () => {
|
||||
/** An imported GPX tour on trip 5: no waypoints, the recorded line as it came. */
|
||||
const importedTour = async (tripId = 5) => {
|
||||
|
||||
@@ -88,8 +88,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'تخطيط كجولة جديدة',
|
||||
'tours.conversion.discard': 'تجاهل',
|
||||
'tours.conversion.adopted': 'فُتح كجولة جديدة. احفظه للاحتفاظ به؛ يبقى الاستيراد كما هو.',
|
||||
'tours.derivedFrom': 'مخطّطة من {name}',
|
||||
'tours.derivedFromHint': 'محسوبة من جديد انطلاقًا من استيراد، لذا قد تختلف عن الأثر المسجّل.',
|
||||
'tours.derivedFrom': 'مبنية على {name}',
|
||||
'tours.derivedFromHint': 'أُنشئت من هذه الجولة. الجولتان مستقلتان: التغيير في إحداهما لا يصل إلى الأخرى.',
|
||||
'tours.delete.confirmBody':
|
||||
'هل تريد حذف هذه الجولة نهائيًا؟ ستُحذف مساراتها ونقاطها القابلة للتحرير وتعييناتها للأيام. لا يمكن التراجع عن هذا الإجراء.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -277,5 +277,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'النوع',
|
||||
'tours.filter.empty': 'لا توجد جولة تطابق هذه المرشحات.',
|
||||
'tours.filter.reset': 'إعادة ضبط المرشحات',
|
||||
'tours.copy.action': 'حفظ كجولة جديدة',
|
||||
'tours.copy.hint': 'ينسخ هذه الجولة كجولة جديدة مستقلة دون أيامها. يبقى الأصل كما هو.',
|
||||
'tours.copy.name': '{name} (نسخة)',
|
||||
'tours.copy.saved': 'تم الحفظ كجولة جديدة',
|
||||
'tours.copy.failed': 'تعذّر نسخ الجولة.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -73,8 +73,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Yeni tur kimi planlaşdır',
|
||||
'tours.conversion.discard': 'Ləğv et',
|
||||
'tours.conversion.adopted': 'Yeni tur kimi açıldı. Qalması üçün onu yadda saxlayın; idxal olduğu kimi qalır.',
|
||||
'tours.derivedFrom': '{name} əsasında planlaşdırılıb',
|
||||
'tours.derivedFromHint': 'İdxaldan yenidən hesablanıb, ona görə yazılmış izdən fərqlənə bilər.',
|
||||
'tours.derivedFrom': 'Əsası: {name}',
|
||||
'tours.derivedFromHint': 'Bu turdan yaradılıb. İkisi müstəqildir: birindəki dəyişiklik digərinə çatmır.',
|
||||
'tours.delete.confirmBody':
|
||||
'Bu tur həmişəlik silinsin? Onun marşrutu, redaktə edilə bilən nöqtələri və gün təyinatları silinəcək. Bu əməliyyatı geri qaytarmaq mümkün deyil.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -266,5 +266,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Növ',
|
||||
'tours.filter.empty': 'Bu filtrlərə uyğun tur yoxdur.',
|
||||
'tours.filter.reset': 'Filtrləri sıfırla',
|
||||
'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)',
|
||||
'tours.copy.saved': 'Yeni tur kimi saxlanıldı',
|
||||
'tours.copy.failed': 'Turu kopyalamaq olmadı.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -76,9 +76,9 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Planejar como novo passeio',
|
||||
'tours.conversion.discard': 'Descartar',
|
||||
'tours.conversion.adopted': 'Aberto como novo passeio. Salve para mantê-lo; a importação continua como está.',
|
||||
'tours.derivedFrom': 'Planejado a partir de {name}',
|
||||
'tours.derivedFrom': 'Baseado em {name}',
|
||||
'tours.derivedFromHint':
|
||||
'Rota recalculada a partir de uma importação, por isso pode ser diferente do traçado gravado.',
|
||||
'Criado a partir deste passeio. Os dois são independentes: uma mudança em um não chega ao outro.',
|
||||
'tours.delete.confirmBody':
|
||||
'Excluir este passeio permanentemente? A rota, os pontos editáveis e as atribuições aos dias serão removidos. Esta ação não pode ser desfeita.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -271,5 +271,11 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Tipo',
|
||||
'tours.filter.empty': 'Nenhum passeio corresponde a esses filtros.',
|
||||
'tours.filter.reset': 'Limpar filtros',
|
||||
'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á.',
|
||||
'tours.copy.name': '{name} (cópia)',
|
||||
'tours.copy.saved': 'Salvo como novo passeio',
|
||||
'tours.copy.failed': 'Não foi possível copiar o passeio.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -77,9 +77,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.discard': 'Descarta',
|
||||
'tours.conversion.adopted':
|
||||
'Oberta com a excursió nova. Desa-la per conservar-la; la importació es queda tal com és.',
|
||||
'tours.derivedFrom': 'Planificada a partir de {name}',
|
||||
'tours.derivedFromHint':
|
||||
"Ruta calculada de nou a partir d'una importació, així que pot diferir de la traça enregistrada.",
|
||||
'tours.derivedFrom': 'Basada en {name}',
|
||||
'tours.derivedFromHint': 'Creada a partir d’aquesta excursió. Són independents: un canvi en una no arriba a l’altra.',
|
||||
'tours.delete.confirmBody':
|
||||
'Vols eliminar aquest itinerari permanentment? Se n’eliminaran la ruta, els punts editables i les assignacions als dies. Aquesta acció no es pot desfer.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -272,5 +271,11 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Tipus',
|
||||
'tours.filter.empty': 'Cap excursió no coincideix amb aquests filtres.',
|
||||
'tours.filter.reset': 'Restableix els filtres',
|
||||
'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à.',
|
||||
'tours.copy.name': '{name} (còpia)',
|
||||
'tours.copy.saved': 'Desada com a excursió nova',
|
||||
'tours.copy.failed': 'No s’ha pogut copiar l’excursió.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -78,8 +78,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Naplánovat jako nový výlet',
|
||||
'tours.conversion.discard': 'Zahodit',
|
||||
'tours.conversion.adopted': 'Otevřeno jako nový výlet. Uložte ho, aby zůstal zachován; import zůstane beze změny.',
|
||||
'tours.derivedFrom': 'Naplánováno z {name}',
|
||||
'tours.derivedFromHint': 'Trasa je z importu vypočtena znovu, proto se může od zaznamenané stopy lišit.',
|
||||
'tours.derivedFrom': 'Vychází z {name}',
|
||||
'tours.derivedFromHint': 'Vznikla z této túry. Obě jsou nezávislé: změna jedné se druhé nedotkne.',
|
||||
'tours.delete.confirmBody':
|
||||
'Opravdu chcete tento výlet trvale odstranit? Jeho trasu, upravitelné body a přiřazení ke dnům odstraníte. Tuto akci nelze vrátit zpět.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -266,5 +266,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Druh',
|
||||
'tours.filter.empty': 'Těmto filtrům neodpovídá žádná túra.',
|
||||
'tours.filter.reset': 'Zrušit filtry',
|
||||
'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)',
|
||||
'tours.copy.saved': 'Uloženo jako nová túra',
|
||||
'tours.copy.failed': 'Túru se nepodařilo zkopírovat.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -76,8 +76,9 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.discard': 'Verwerfen',
|
||||
'tours.conversion.adopted':
|
||||
'Als neue Tour geöffnet. Speichere sie, um sie zu behalten; der Import bleibt, wie er ist.',
|
||||
'tours.derivedFrom': 'Geplant aus {name}',
|
||||
'tours.derivedFromHint': 'Aus einem Import neu geroutet, kann daher von der aufgezeichneten Strecke abweichen.',
|
||||
'tours.derivedFrom': 'Basiert auf {name}',
|
||||
'tours.derivedFromHint':
|
||||
'Aus dieser Tour entstanden. Beide sind unabhängig: Eine Änderung an der einen erreicht die andere nicht.',
|
||||
'tours.delete.confirmBody':
|
||||
'Diese Tour dauerhaft löschen? Ihre Route, bearbeitbaren Wegpunkte und Tageszuweisungen werden entfernt. Dieser Vorgang kann nicht rückgängig gemacht werden.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -273,6 +274,11 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Art',
|
||||
'tours.filter.empty': 'Keine Tour passt zu diesen Filtern.',
|
||||
'tours.filter.reset': 'Filter zurücksetzen',
|
||||
'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)',
|
||||
'tours.copy.saved': 'Als neue Tour gespeichert',
|
||||
'tours.copy.failed': 'Die Tour konnte nicht kopiert werden.',
|
||||
};
|
||||
|
||||
export default tours;
|
||||
|
||||
@@ -74,8 +74,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Plan as new tour',
|
||||
'tours.conversion.discard': 'Discard',
|
||||
'tours.conversion.adopted': 'Opened as a new tour. Save it to keep it; the import stays as it is.',
|
||||
'tours.derivedFrom': 'Planned from {name}',
|
||||
'tours.derivedFromHint': 'Routed anew from an import, so it can differ from the recorded track.',
|
||||
'tours.derivedFrom': 'Based on {name}',
|
||||
'tours.derivedFromHint': 'Made from this tour. The two are independent: a change to one does not reach the other.',
|
||||
'tours.delete.confirmBody':
|
||||
'Delete this Tour permanently? Its route, editable waypoints and day assignments will be removed. This action cannot be undone.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -265,5 +265,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Type',
|
||||
'tours.filter.empty': 'No tour matches these filters.',
|
||||
'tours.filter.reset': 'Reset filters',
|
||||
'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)',
|
||||
'tours.copy.saved': 'Saved as a new tour',
|
||||
'tours.copy.failed': 'The tour could not be copied.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -77,9 +77,9 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.discard': 'Descartar',
|
||||
'tours.conversion.adopted':
|
||||
'Abierta como excursión nueva. Guárdala para conservarla; la importación se queda como está.',
|
||||
'tours.derivedFrom': 'Planificada a partir de {name}',
|
||||
'tours.derivedFrom': 'Basada en {name}',
|
||||
'tours.derivedFromHint':
|
||||
'Ruta calculada de nuevo a partir de una importación, así que puede diferir del track grabado.',
|
||||
'Creada a partir de esta excursión. Son independientes: un cambio en una no llega a la otra.',
|
||||
'tours.delete.confirmBody':
|
||||
'¿Eliminar esta ruta permanentemente? Se eliminarán el recorrido, los puntos editables y las asignaciones a días. Esta acción no se puede deshacer.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -270,5 +270,11 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Tipo',
|
||||
'tours.filter.empty': 'Ninguna excursión coincide con estos filtros.',
|
||||
'tours.filter.reset': 'Restablecer filtros',
|
||||
'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á.',
|
||||
'tours.copy.name': '{name} (copia)',
|
||||
'tours.copy.saved': 'Guardada como nueva excursión',
|
||||
'tours.copy.failed': 'No se pudo copiar la excursión.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -75,8 +75,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Planeeri uue retkena',
|
||||
'tours.conversion.discard': 'Loobu',
|
||||
'tours.conversion.adopted': 'Avatud uue retkena. Salvesta see, et see alles jääks; import jääb muutmata.',
|
||||
'tours.derivedFrom': 'Planeeritud allikast {name}',
|
||||
'tours.derivedFromHint': 'Impordist uuesti arvutatud, seega võib see salvestatud rajast erineda.',
|
||||
'tours.derivedFrom': 'Põhineb matkal {name}',
|
||||
'tours.derivedFromHint': 'Tehtud sellest matkast. Need on sõltumatud: muudatus ühes ei jõua teise.',
|
||||
'tours.delete.confirmBody':
|
||||
'Kas kustutada see matk jäädavalt? Selle marsruut, muudetavad vahepunktid ja päevadele määramised eemaldatakse. Seda toimingut ei saa tagasi võtta.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -268,5 +268,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Liik',
|
||||
'tours.filter.empty': 'Ükski matk ei vasta neile filtritele.',
|
||||
'tours.filter.reset': 'Lähtesta filtrid',
|
||||
'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)',
|
||||
'tours.copy.saved': 'Salvestatud uue matkana',
|
||||
'tours.copy.failed': 'Matka ei õnnestunud kopeerida.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -77,8 +77,9 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.discard': 'Abandonner',
|
||||
'tours.conversion.adopted':
|
||||
"Ouverte comme nouvelle sortie. Enregistrez-la pour la conserver ; l'import reste tel quel.",
|
||||
'tours.derivedFrom': 'Planifiée à partir de {name}',
|
||||
'tours.derivedFromHint': "Itinéraire recalculé à partir d'un import, il peut donc différer de la trace enregistrée.",
|
||||
'tours.derivedFrom': 'Basée sur {name}',
|
||||
'tours.derivedFromHint':
|
||||
'Créée à partir de cette sortie. Les deux sont indépendantes : une modification de l’une n’atteint pas l’autre.',
|
||||
'tours.delete.confirmBody':
|
||||
'Supprimer définitivement cette randonnée ? Son itinéraire, ses points modifiables et ses affectations aux jours seront supprimés. Cette action est irréversible.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -275,5 +276,11 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Type', // same-as-en
|
||||
'tours.filter.empty': 'Aucune sortie ne correspond à ces filtres.',
|
||||
'tours.filter.reset': 'Réinitialiser les filtres',
|
||||
'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.',
|
||||
'tours.copy.name': '{name} (copie)',
|
||||
'tours.copy.saved': 'Enregistrée comme nouvelle sortie',
|
||||
'tours.copy.failed': 'La sortie n’a pas pu être copiée.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -76,9 +76,9 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Σχεδιασμός ως νέα εκδρομή',
|
||||
'tours.conversion.discard': 'Απόρριψη',
|
||||
'tours.conversion.adopted': 'Άνοιξε ως νέα εκδρομή. Αποθηκεύστε την για να τη διατηρήσετε· η εισαγωγή μένει ως έχει.',
|
||||
'tours.derivedFrom': 'Σχεδιάστηκε από {name}',
|
||||
'tours.derivedFrom': 'Βασισμένη σε {name}',
|
||||
'tours.derivedFromHint':
|
||||
'Δρομολογήθηκε εκ νέου από μια εισαγωγή, οπότε μπορεί να διαφέρει από το καταγεγραμμένο ίχνος.',
|
||||
'Δημιουργήθηκε από αυτή την εκδρομή. Είναι ανεξάρτητες: μια αλλαγή στη μία δεν φτάνει στην άλλη.',
|
||||
'tours.delete.confirmBody':
|
||||
'Να διαγραφεί οριστικά αυτή η διαδρομή; Η πορεία, τα επεξεργάσιμα σημεία και οι αναθέσεις σε ημέρες θα αφαιρεθούν. Αυτή η ενέργεια δεν αναιρείται.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -277,5 +277,11 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Είδος',
|
||||
'tours.filter.empty': 'Καμία εκδρομή δεν ταιριάζει με αυτά τα φίλτρα.',
|
||||
'tours.filter.reset': 'Επαναφορά φίλτρων',
|
||||
'tours.copy.action': 'Αποθήκευση ως νέα εκδρομή',
|
||||
'tours.copy.hint':
|
||||
'Αντιγράφει αυτή την εκδρομή ως νέα, ανεξάρτητη εκδρομή χωρίς τις μέρες της. Το πρωτότυπο μένει ως έχει.',
|
||||
'tours.copy.name': '{name} (αντίγραφο)',
|
||||
'tours.copy.saved': 'Αποθηκεύτηκε ως νέα εκδρομή',
|
||||
'tours.copy.failed': 'Η εκδρομή δεν αντιγράφηκε.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -75,8 +75,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Tervezés új túraként',
|
||||
'tours.conversion.discard': 'Elvetés',
|
||||
'tours.conversion.adopted': 'Megnyitva új túraként. Mentsd el, hogy megmaradjon; az import változatlan marad.',
|
||||
'tours.derivedFrom': 'Tervezve ebből: {name}',
|
||||
'tours.derivedFromHint': 'Importból újratervezve, ezért eltérhet a rögzített nyomvonaltól.',
|
||||
'tours.derivedFrom': 'Alapja: {name}',
|
||||
'tours.derivedFromHint': 'Ebből a túrából készült. A kettő független: az egyik módosítása nem éri el a másikat.',
|
||||
'tours.delete.confirmBody':
|
||||
'Végleg törli ezt a túrát? Az útvonala, szerkeszthető útvonalpontjai és a napokhoz rendelései is törlődnek. A művelet nem vonható vissza.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -272,5 +272,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Fajta',
|
||||
'tours.filter.empty': 'Egy túra sem felel meg ezeknek a szűrőknek.',
|
||||
'tours.filter.reset': 'Szűrők törlése',
|
||||
'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)',
|
||||
'tours.copy.saved': 'Új túraként mentve',
|
||||
'tours.copy.failed': 'A túrát nem sikerült másolni.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -72,8 +72,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Rencanakan sebagai tur baru',
|
||||
'tours.conversion.discard': 'Buang',
|
||||
'tours.conversion.adopted': 'Dibuka sebagai tur baru. Simpan untuk mempertahankannya; impor tetap seperti semula.',
|
||||
'tours.derivedFrom': 'Direncanakan dari {name}',
|
||||
'tours.derivedFromHint': 'Dirutekan ulang dari impor, jadi bisa berbeda dari jejak yang direkam.',
|
||||
'tours.derivedFrom': 'Berdasarkan {name}',
|
||||
'tours.derivedFromHint': 'Dibuat dari tur ini. Keduanya mandiri: perubahan pada satu tidak sampai ke yang lain.',
|
||||
'tours.delete.confirmBody':
|
||||
'Hapus tur ini secara permanen? Rute, titik arah yang dapat diedit, dan penetapan harinya akan dihapus. Tindakan ini tidak dapat dibatalkan.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -265,5 +265,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Jenis',
|
||||
'tours.filter.empty': 'Tidak ada tur yang cocok dengan filter ini.',
|
||||
'tours.filter.reset': 'Atur ulang filter',
|
||||
'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)',
|
||||
'tours.copy.saved': 'Disimpan sebagai tur baru',
|
||||
'tours.copy.failed': 'Tur tidak dapat disalin.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -76,8 +76,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Pianifica come nuova escursione',
|
||||
'tours.conversion.discard': 'Scarta',
|
||||
'tours.conversion.adopted': "Aperta come nuova escursione. Salvala per conservarla; l'importazione resta com'è.",
|
||||
'tours.derivedFrom': 'Pianificata da {name}',
|
||||
'tours.derivedFromHint': "Percorso ricalcolato da un'importazione, quindi può differire dalla traccia registrata.",
|
||||
'tours.derivedFrom': 'Basato su {name}',
|
||||
'tours.derivedFromHint': 'Creato da questo tour. Sono indipendenti: una modifica a uno non raggiunge l’altro.',
|
||||
'tours.delete.confirmBody':
|
||||
'Eliminare definitivamente questo tour? Verranno rimossi il percorso, i waypoint modificabili e le assegnazioni ai giorni. L’azione non può essere annullata.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -272,5 +272,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Tipo',
|
||||
'tours.filter.empty': 'Nessun tour corrisponde a questi filtri.',
|
||||
'tours.filter.reset': 'Reimposta filtri',
|
||||
'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)',
|
||||
'tours.copy.saved': 'Salvato come nuovo tour',
|
||||
'tours.copy.failed': 'Non è stato possibile copiare il tour.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -72,8 +72,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.discard': '破棄',
|
||||
'tours.conversion.adopted':
|
||||
'新しいツアーとして開きました。残すには保存してください。インポートしたものはそのまま残ります。',
|
||||
'tours.derivedFrom': '{name}から計画',
|
||||
'tours.derivedFromHint': 'インポートから改めてルートを引いたため、記録されたトラックと異なる場合があります。',
|
||||
'tours.derivedFrom': '{name} をもとに作成',
|
||||
'tours.derivedFromHint': 'このツアーから作成されました。2つは独立していて、一方の変更はもう一方に反映されません。',
|
||||
'tours.delete.confirmBody':
|
||||
'このツアーを完全に削除しますか?ルート、編集可能な経由地点、日程への割り当ても削除されます。この操作は取り消せません。',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -259,5 +259,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': '種類',
|
||||
'tours.filter.empty': 'この条件に合うツアーはありません。',
|
||||
'tours.filter.reset': 'フィルターをリセット',
|
||||
'tours.copy.action': '新しいツアーとして保存',
|
||||
'tours.copy.hint': 'このツアーを日程なしの独立した新しいツアーとしてコピーします。元のツアーはそのままです。',
|
||||
'tours.copy.name': '{name}(コピー)',
|
||||
'tours.copy.saved': '新しいツアーとして保存しました',
|
||||
'tours.copy.failed': 'ツアーをコピーできませんでした。',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -69,8 +69,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': '새 투어로 계획',
|
||||
'tours.conversion.discard': '취소',
|
||||
'tours.conversion.adopted': '새 투어로 열었습니다. 유지하려면 저장하세요. 가져온 항목은 그대로 유지됩니다.',
|
||||
'tours.derivedFrom': '{name}에서 계획됨',
|
||||
'tours.derivedFromHint': '가져온 항목에서 경로를 새로 계산했으므로 기록된 트랙과 다를 수 있습니다.',
|
||||
'tours.derivedFrom': '{name} 기반',
|
||||
'tours.derivedFromHint': '이 투어에서 만들어졌습니다. 둘은 독립적이라 한쪽을 바꿔도 다른 쪽에는 반영되지 않습니다.',
|
||||
'tours.delete.confirmBody':
|
||||
'이 투어를 영구 삭제할까요? 경로, 편집 가능한 경유 지점 및 날짜 배정이 삭제됩니다. 이 작업은 되돌릴 수 없습니다.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -257,5 +257,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': '종류',
|
||||
'tours.filter.empty': '이 필터에 맞는 투어가 없습니다.',
|
||||
'tours.filter.reset': '필터 초기화',
|
||||
'tours.copy.action': '새 투어로 저장',
|
||||
'tours.copy.hint': '이 투어를 날짜 없이 독립된 새 투어로 복사합니다. 원본은 그대로입니다.',
|
||||
'tours.copy.name': '{name} (사본)',
|
||||
'tours.copy.saved': '새 투어로 저장했습니다',
|
||||
'tours.copy.failed': '투어를 복사하지 못했습니다.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -76,8 +76,9 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Als nieuwe tocht plannen',
|
||||
'tours.conversion.discard': 'Verwerpen',
|
||||
'tours.conversion.adopted': 'Geopend als nieuwe tocht. Sla hem op om hem te bewaren; de import blijft zoals hij is.',
|
||||
'tours.derivedFrom': 'Gepland op basis van {name}',
|
||||
'tours.derivedFromHint': 'Opnieuw gerouteerd vanuit een import, dus kan afwijken van de opgenomen track.',
|
||||
'tours.derivedFrom': 'Gebaseerd op {name}',
|
||||
'tours.derivedFromHint':
|
||||
'Gemaakt van deze tocht. Ze zijn onafhankelijk: een wijziging aan de ene bereikt de andere niet.',
|
||||
'tours.delete.confirmBody':
|
||||
'Deze tour definitief verwijderen? De route, bewerkbare routepunten en dagtoewijzingen worden verwijderd. Dit kan niet ongedaan worden gemaakt.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -271,5 +272,11 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Soort',
|
||||
'tours.filter.empty': 'Geen tocht past bij deze filters.',
|
||||
'tours.filter.reset': 'Filters wissen',
|
||||
'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.',
|
||||
'tours.copy.name': '{name} (kopie)',
|
||||
'tours.copy.saved': 'Opgeslagen als nieuwe tocht',
|
||||
'tours.copy.failed': 'De tocht kon niet worden gekopieerd.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -84,8 +84,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Zaplanuj jako nową wycieczkę',
|
||||
'tours.conversion.discard': 'Odrzuć',
|
||||
'tours.conversion.adopted': 'Otwarto jako nową wycieczkę. Zapisz ją, aby ją zachować; import pozostaje bez zmian.',
|
||||
'tours.derivedFrom': 'Zaplanowano na podstawie {name}',
|
||||
'tours.derivedFromHint': 'Trasa wyznaczona od nowa z importu, więc może się różnić od zarejestrowanego śladu.',
|
||||
'tours.derivedFrom': 'Na podstawie: {name}',
|
||||
'tours.derivedFromHint': 'Utworzona z tej trasy. Obie są niezależne: zmiana w jednej nie dotyczy drugiej.',
|
||||
'tours.delete.confirmBody':
|
||||
'Usunąć tę trasę na stałe? Jej przebieg, edytowalne punkty trasy i przypisania do dni zostaną usunięte. Tej czynności nie można cofnąć.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -279,5 +279,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Rodzaj',
|
||||
'tours.filter.empty': 'Żadna trasa nie pasuje do tych filtrów.',
|
||||
'tours.filter.reset': 'Wyczyść filtry',
|
||||
'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)',
|
||||
'tours.copy.saved': 'Zapisano jako nową trasę',
|
||||
'tours.copy.failed': 'Nie udało się skopiować trasy.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -84,8 +84,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.discard': 'Отклонить',
|
||||
'tours.conversion.adopted':
|
||||
'Открыто как новый тур. Сохраните его, чтобы не потерять; импорт останется без изменений.',
|
||||
'tours.derivedFrom': 'Спланировано на основе {name}',
|
||||
'tours.derivedFromHint': 'Маршрут заново проложен по импорту, поэтому может отличаться от записанного трека.',
|
||||
'tours.derivedFrom': 'На основе: {name}',
|
||||
'tours.derivedFromHint': 'Создан из этого похода. Они независимы: изменение одного не затрагивает другой.',
|
||||
'tours.delete.confirmBody':
|
||||
'Удалить этот маршрут навсегда? Его путь, редактируемые точки и назначения на дни будут удалены. Это действие нельзя отменить.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -282,5 +282,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Тип',
|
||||
'tours.filter.empty': 'Ни один поход не подходит под эти фильтры.',
|
||||
'tours.filter.reset': 'Сбросить фильтры',
|
||||
'tours.copy.action': 'Сохранить как новый поход',
|
||||
'tours.copy.hint': 'Копирует этот поход как новый, самостоятельный поход без его дней. Оригинал остаётся как есть.',
|
||||
'tours.copy.name': '{name} (копия)',
|
||||
'tours.copy.saved': 'Сохранено как новый поход',
|
||||
'tours.copy.failed': 'Не удалось скопировать поход.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -79,8 +79,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Naplánovať ako novú túru',
|
||||
'tours.conversion.discard': 'Zahodiť',
|
||||
'tours.conversion.adopted': 'Otvorené ako nová túra. Uložte ju, aby sa zachovala; import zostane nezmenený.',
|
||||
'tours.derivedFrom': 'Naplánované z {name}',
|
||||
'tours.derivedFromHint': 'Trasa je z importu vypočítaná nanovo, preto sa môže od zaznamenanej stopy líšiť.',
|
||||
'tours.derivedFrom': 'Vychádza z {name}',
|
||||
'tours.derivedFromHint': 'Vznikla z tejto túry. Obe sú nezávislé: zmena jednej sa druhej nedotkne.',
|
||||
'tours.delete.confirmBody':
|
||||
'Naozaj chcete tento výlet natrvalo odstrániť? Odstráni sa jeho trasa, upraviteľné body a priradenia k dňom. Túto akciu nemožno vrátiť späť.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -267,5 +267,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Druh',
|
||||
'tours.filter.empty': 'Týmto filtrom nezodpovedá žiadna túra.',
|
||||
'tours.filter.reset': 'Zrušiť filtre',
|
||||
'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)',
|
||||
'tours.copy.saved': 'Uložené ako nová túra',
|
||||
'tours.copy.failed': 'Túru sa nepodarilo skopírovať.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -74,8 +74,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Planera som ny tur',
|
||||
'tours.conversion.discard': 'Kasta',
|
||||
'tours.conversion.adopted': 'Öppnad som ny tur. Spara den för att behålla den; importen förblir som den är.',
|
||||
'tours.derivedFrom': 'Planerad från {name}',
|
||||
'tours.derivedFromHint': 'Nyberäknad från en import, så den kan skilja sig från det inspelade spåret.',
|
||||
'tours.derivedFrom': 'Baserad på {name}',
|
||||
'tours.derivedFromHint': 'Skapad från den här turen. De är oberoende: en ändring i den ena når inte den andra.',
|
||||
'tours.delete.confirmBody':
|
||||
'Ta bort den här turen permanent? Rutten, de redigerbara rutpunkterna och dagstilldelningarna tas bort. Åtgärden kan inte ångras.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -264,5 +264,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Typ',
|
||||
'tours.filter.empty': 'Ingen tur matchar de här filtren.',
|
||||
'tours.filter.reset': 'Återställ filter',
|
||||
'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)',
|
||||
'tours.copy.saved': 'Sparad som ny tur',
|
||||
'tours.copy.failed': 'Turen kunde inte kopieras.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -68,8 +68,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'วางแผนเป็นทัวร์ใหม่',
|
||||
'tours.conversion.discard': 'ทิ้ง',
|
||||
'tours.conversion.adopted': 'เปิดเป็นทัวร์ใหม่แล้ว บันทึกเพื่อเก็บไว้ รายการที่นำเข้าจะยังคงเหมือนเดิม',
|
||||
'tours.derivedFrom': 'วางแผนจาก {name}',
|
||||
'tours.derivedFromHint': 'คำนวณเส้นทางใหม่จากรายการที่นำเข้า จึงอาจต่างจากแทร็กที่บันทึกไว้',
|
||||
'tours.derivedFrom': 'อิงจาก {name}',
|
||||
'tours.derivedFromHint': 'สร้างจากทัวร์นี้ ทั้งสองเป็นอิสระต่อกัน การเปลี่ยนแปลงอันหนึ่งจะไม่ไปถึงอีกอัน',
|
||||
'tours.delete.confirmBody':
|
||||
'ลบทัวร์นี้อย่างถาวรหรือไม่? เส้นทาง จุดแวะที่แก้ไขได้ และการจัดตารางลงวันจะถูกลบ การดำเนินการนี้ไม่สามารถยกเลิกได้',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -253,5 +253,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'ประเภท',
|
||||
'tours.filter.empty': 'ไม่มีทัวร์ที่ตรงกับตัวกรองเหล่านี้',
|
||||
'tours.filter.reset': 'รีเซ็ตตัวกรอง',
|
||||
'tours.copy.action': 'บันทึกเป็นทัวร์ใหม่',
|
||||
'tours.copy.hint': 'คัดลอกทัวร์นี้เป็นทัวร์ใหม่ที่แยกเป็นอิสระโดยไม่รวมวัน ต้นฉบับจะคงเดิม',
|
||||
'tours.copy.name': '{name} (สำเนา)',
|
||||
'tours.copy.saved': 'บันทึกเป็นทัวร์ใหม่แล้ว',
|
||||
'tours.copy.failed': 'คัดลอกทัวร์ไม่สำเร็จ',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -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 = 238;
|
||||
const GENERAL_KEY_COUNT = 243;
|
||||
|
||||
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 238 general source keys', () => {
|
||||
it('covers all 27 supported locales with 243 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);
|
||||
|
||||
@@ -74,8 +74,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Yeni tur olarak planla',
|
||||
'tours.conversion.discard': 'Vazgeç',
|
||||
'tours.conversion.adopted': 'Yeni tur olarak açıldı. Saklamak için kaydedin; içe aktarım olduğu gibi kalır.',
|
||||
'tours.derivedFrom': '{name} temel alınarak planlandı',
|
||||
'tours.derivedFromHint': 'Bir içe aktarımdan yeniden rotalandı, bu yüzden kaydedilen izden farklı olabilir.',
|
||||
'tours.derivedFrom': 'Temeli: {name}',
|
||||
'tours.derivedFromHint': 'Bu turdan oluşturuldu. İkisi bağımsızdır: birindeki değişiklik diğerine ulaşmaz.',
|
||||
'tours.delete.confirmBody':
|
||||
'Bu tur kalıcı olarak silinsin mi? Rotası, düzenlenebilir ara noktaları ve gün atamaları kaldırılacak. Bu işlem geri alınamaz.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -266,5 +266,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Tür',
|
||||
'tours.filter.empty': 'Bu filtrelere uyan tur yok.',
|
||||
'tours.filter.reset': 'Filtreleri sıfırla',
|
||||
'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)',
|
||||
'tours.copy.saved': 'Yeni tur olarak kaydedildi',
|
||||
'tours.copy.failed': 'Tur kopyalanamadı.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -83,8 +83,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Спланувати як новий тур',
|
||||
'tours.conversion.discard': 'Відхилити',
|
||||
'tours.conversion.adopted': 'Відкрито як новий тур. Збережіть його, щоб не втратити; імпорт залишиться без змін.',
|
||||
'tours.derivedFrom': 'Сплановано на основі {name}',
|
||||
'tours.derivedFromHint': 'Маршрут заново прокладено за імпортом, тому він може відрізнятися від записаного треку.',
|
||||
'tours.derivedFrom': 'На основі: {name}',
|
||||
'tours.derivedFromHint': 'Створено з цього походу. Вони незалежні: зміна одного не торкається іншого.',
|
||||
'tours.delete.confirmBody':
|
||||
'Видалити цей маршрут назавжди? Його шлях, редаговані точки та призначення на дні буде видалено. Цю дію не можна скасувати.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -279,5 +279,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Тип',
|
||||
'tours.filter.empty': 'Жоден похід не відповідає цим фільтрам.',
|
||||
'tours.filter.reset': 'Скинути фільтри',
|
||||
'tours.copy.action': 'Зберегти як новий похід',
|
||||
'tours.copy.hint': 'Копіює цей похід як новий, самостійний похід без його днів. Оригінал лишається як є.',
|
||||
'tours.copy.name': '{name} (копія)',
|
||||
'tours.copy.saved': 'Збережено як новий похід',
|
||||
'tours.copy.failed': 'Не вдалося скопіювати похід.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -73,8 +73,9 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': 'Lên kế hoạch thành chuyến tham quan mới',
|
||||
'tours.conversion.discard': 'Bỏ',
|
||||
'tours.conversion.adopted': 'Đã mở thành chuyến tham quan mới. Hãy lưu để giữ lại; bản nhập vẫn giữ nguyên.',
|
||||
'tours.derivedFrom': 'Lên kế hoạch từ {name}',
|
||||
'tours.derivedFromHint': 'Được định tuyến lại từ một bản nhập nên có thể khác với vệt hành trình đã ghi.',
|
||||
'tours.derivedFrom': 'Dựa trên {name}',
|
||||
'tours.derivedFromHint':
|
||||
'Được tạo từ chuyến này. Hai chuyến độc lập: thay đổi ở chuyến này không ảnh hưởng chuyến kia.',
|
||||
'tours.delete.confirmBody':
|
||||
'Xóa vĩnh viễn chuyến đi này? Tuyến đường, các điểm trung gian có thể chỉnh sửa và lịch gán ngày sẽ bị xóa. Không thể hoàn tác thao tác này.',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
@@ -265,5 +266,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': 'Loại',
|
||||
'tours.filter.empty': 'Không có chuyến đi bộ nào khớp với bộ lọc này.',
|
||||
'tours.filter.reset': 'Đặt lại bộ lọc',
|
||||
'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)',
|
||||
'tours.copy.saved': 'Đã lưu thành chuyến đi bộ mới',
|
||||
'tours.copy.failed': 'Không thể sao chép chuyến đi bộ.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -66,8 +66,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': '規劃為新遊程',
|
||||
'tours.conversion.discard': '捨棄',
|
||||
'tours.conversion.adopted': '已作為新遊程開啟。儲存後即可保留;匯入的內容會保持原樣。',
|
||||
'tours.derivedFrom': '根據 {name} 規劃',
|
||||
'tours.derivedFromHint': '根據匯入內容重新規劃,可能與記錄的軌跡不同。',
|
||||
'tours.derivedFrom': '以 {name} 為基礎',
|
||||
'tours.derivedFromHint': '由此遊程建立。兩者互相獨立:修改其中一個不會影響另一個。',
|
||||
'tours.delete.confirmBody': '要永久刪除此行程嗎?其路線、可編輯的途經點和日期指派都會一併移除。此操作無法復原。',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
'所選內容至少包含一個行程。所有選取的地點都會永久刪除且無法復原;行程路線、可編輯的途經點和日期指派也會一併移除。',
|
||||
@@ -243,5 +243,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': '類型',
|
||||
'tours.filter.empty': '沒有符合這些篩選條件的遊程。',
|
||||
'tours.filter.reset': '重設篩選',
|
||||
'tours.copy.action': '另存為新遊程',
|
||||
'tours.copy.hint': '將此遊程複製為不含日程的獨立新遊程。原遊程保持不變。',
|
||||
'tours.copy.name': '{name}(副本)',
|
||||
'tours.copy.saved': '已另存為新遊程',
|
||||
'tours.copy.failed': '無法複製遊程。',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -67,8 +67,8 @@ const tours: TranslationStrings = {
|
||||
'tours.conversion.adopt': '规划为新游览路线',
|
||||
'tours.conversion.discard': '放弃',
|
||||
'tours.conversion.adopted': '已作为新游览路线打开。保存后即可保留;导入的内容保持不变。',
|
||||
'tours.derivedFrom': '根据 {name} 规划',
|
||||
'tours.derivedFromHint': '根据导入内容重新规划,可能与录制的轨迹不同。',
|
||||
'tours.derivedFrom': '基于 {name}',
|
||||
'tours.derivedFromHint': '由此路线创建。两者相互独立:修改其中一个不会影响另一个。',
|
||||
'tours.delete.confirmBody': '要永久删除此行程吗?其路线、可编辑的途经点和日期安排都将被删除。此操作无法撤销。',
|
||||
'tours.delete.bulkConfirmBody':
|
||||
'所选内容至少包含一个行程。所有选中的地点都将被永久删除且无法恢复;行程路线、可编辑的途经点和日期安排也会一并删除。',
|
||||
@@ -244,5 +244,10 @@ const tours: TranslationStrings = {
|
||||
'tours.filter.type': '类型',
|
||||
'tours.filter.empty': '没有符合这些筛选条件的路线。',
|
||||
'tours.filter.reset': '重置筛选',
|
||||
'tours.copy.action': '另存为新路线',
|
||||
'tours.copy.hint': '将此路线复制为不含日程的独立新路线。原路线保持不变。',
|
||||
'tours.copy.name': '{name}(副本)',
|
||||
'tours.copy.saved': '已另存为新路线',
|
||||
'tours.copy.failed': '无法复制路线。',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
tourSourceCheckResponseSchema,
|
||||
type TourProvenance,
|
||||
} from './tour-provenance.schema';
|
||||
import { tourCreateResponseSchema, tourListItemSchema } from './tours.schema';
|
||||
import { tourCopyRequestSchema, tourCreateResponseSchema, tourListItemSchema } from './tours.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -109,3 +109,11 @@ describe('source check answer (MF-03)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save as new tour (P2C-07)', () => {
|
||||
it('takes a trimmed name and refuses an empty or overlong one', () => {
|
||||
expect(tourCopyRequestSchema.parse({ name: ' Ridge (copy) ' })).toEqual({ name: 'Ridge (copy)' });
|
||||
expect(tourCopyRequestSchema.safeParse({ name: ' ' }).success).toBe(false);
|
||||
expect(tourCopyRequestSchema.safeParse({ name: 'x'.repeat(256) }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { tourOriginSchema, tourProvenanceSchema } from './tour-provenance.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Tours contracts — the generalisation of "hike" into a domain of self-contained,
|
||||
* single-day, non-motorized activities that follow a track (hike, bike, run, ski,
|
||||
@@ -244,6 +245,16 @@ export const tourCreateResponseSchema = z.object({
|
||||
});
|
||||
export type TourCreateResponse = z.infer<typeof tourCreateResponseSchema>;
|
||||
|
||||
/**
|
||||
* P2C-07 "save as new tour": the name the copy gets. Everything else comes
|
||||
* from the tour it is copied from; the copy has its own id, line and control
|
||||
* points, and no days.
|
||||
*/
|
||||
export const tourCopyRequestSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
});
|
||||
export type TourCopyRequest = z.infer<typeof tourCopyRequestSchema>;
|
||||
|
||||
/** Saved-tour editor payload: list metadata plus its persisted routing controls. */
|
||||
export const tourDetailResponseSchema = tourCreateResponseSchema;
|
||||
export type TourDetailResponse = z.infer<typeof tourDetailResponseSchema>;
|
||||
|
||||
Reference in New Issue
Block a user