diff --git a/client/src/api/tourCopies.ts b/client/src/api/tourCopies.ts new file mode 100644 index 000000000..eb8f28eff --- /dev/null +++ b/client/src/api/tourCopies.ts @@ -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 => + apiClient.post(`/trips/${tripId}/tours/${placeId}/copy`, body).then((r) => r.data), +}; diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index 11c2cbd23..c8016b128 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -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; /** A wanderer snapshot's source check brought the changed trail in as a new tour (MF-03). */ onSourceReimported?: (tour: TourListItem) => Promise | void; + /** P2C-07: save this tour again as a new, independent one. */ + onSaveAsNew?: (tour: TourListItem) => Promise | 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({ )} + {canEdit && onSaveAsNew && onSaveAsNew(tour)} />} {canEdit && onDelete && ( Promise | void }) { + const { t } = useTranslation(); + const [saving, setSaving] = useState(false); + const save = async () => { + if (saving) return; + setSaving(true); + try { + await onSave(); + } finally { + setSaving(false); + } + }; + return ( + + + void save()} + disabled={saving} + icon={saving ? : } + aria-label={t('tours.copy.action')} + > + {t('tours.copy.action')} + + + + ); +} + /** A tour's files: the count to unfold, each file to open, and an upload for who may change the tour. */ function TourFilesSection({ placeId, diff --git a/client/src/pages/tripPlanner/useTourWorkspace.ts b/client/src/pages/tripPlanner/useTourWorkspace.ts index 3f89fd0c4..80ea6ce9f 100644 --- a/client/src/pages/tripPlanner/useTourWorkspace.ts +++ b/client/src/pages/tripPlanner/useTourWorkspace.ts @@ -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 { diff --git a/client/src/repo/tourRepo.ts b/client/src/repo/tourRepo.ts index ea645b993..cfddb7518 100644 --- a/client/src/repo/tourRepo.ts +++ b/client/src/repo/tourRepo.ts @@ -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 { + 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 { const result = await toursApi.importGpx(tripId, file) await offlineDb.tours.bulkPut(result.tours.map(tour => ({ ...tour, trip_id: Number(tripId) }))) diff --git a/server/src/nest/tours/dto/tour-create.dto.ts b/server/src/nest/tours/dto/tour-create.dto.ts index 5afe58191..152d6af7a 100644 --- a/server/src/nest/tours/dto/tour-create.dto.ts +++ b/server/src/nest/tours/dto/tour-create.dto.ts @@ -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) {} diff --git a/server/src/nest/tours/tours.controller.ts b/server/src/nest/tours/tours.controller.ts index 4900a704a..1000247f8 100644 --- a/server/src/nest/tours/tours.controller.ts +++ b/server/src/nest/tours/tours.controller.ts @@ -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') diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts index 09066a5e0..38c84bf92 100644 --- a/server/src/nest/tours/tours.service.ts +++ b/server/src/nest/tours/tours.service.ts @@ -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 { + 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, diff --git a/server/tests/e2e/tours.e2e.test.ts b/server/tests/e2e/tours.e2e.test.ts index 158e0efb2..86e624e9f 100644 --- a/server/tests/e2e/tours.e2e.test.ts +++ b/server/tests/e2e/tours.e2e.test.ts @@ -548,6 +548,66 @@ describe('Tours e2e (real guards + temp SQLite)', () => { }); }); + describe('save as new tour (P2C-07)', () => { + const copy = ( + placeId: number, + body: Record = { 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) => { diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index d84b757e0..a35aeb739 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -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; diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index c18cb752c..be3f360a4 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -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; diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index 577142b44..e12e3d4ad 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -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; diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index c19ce9055..b49d9471c 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -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; diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 2d568576b..7574f4196 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -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; diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index acaaacb5b..c49f315c7 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -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; diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index c37b1a6ce..51c566e11 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -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; diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index f3297f281..c9d5ab3c9 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -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; diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index 33721842f..c4ac8e1b9 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -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; diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index 1f238518d..910e69c3d 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -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; diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index 41eb950f6..0dcee486f 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -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; diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index 1ffc468d8..aee0996cf 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -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; diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index a874d2d1d..7b02a7d05 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -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; diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index 5150f0b95..f4eca3be5 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -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; diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 30a9cfca4..82e4f6cc2 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -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; diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index 1bd85aba8..ca10dc37e 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -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; diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 617e6de8b..6b3035edd 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -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; diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index f6b48cb53..d9cece0b5 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -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; diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index 973d76ba0..82e2156d0 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -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; diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index 5146c71ce..ed2f66a28 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -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; diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index 3519406e8..677ad63f3 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -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; diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index dda64f854..bd1733ac2 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -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; diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts index a0d906af1..b8eb6a14b 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 = 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); diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 9b0956ff0..9d168e242 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -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; diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 888f5b84c..5776cf74e 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -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; diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index 60e461f95..309a231a9 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -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; diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index 107bc5acf..d09954d60 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -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; diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index 4a00d223b..ccab1ae6b 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -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; diff --git a/shared/src/tours/tour-provenance.schema.test.ts b/shared/src/tours/tour-provenance.schema.test.ts index 9dab0a48e..c86fa91fb 100644 --- a/shared/src/tours/tour-provenance.schema.test.ts +++ b/shared/src/tours/tour-provenance.schema.test.ts @@ -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); + }); +}); diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index 4f4692388..589f5655d 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -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; +/** + * 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; + /** Saved-tour editor payload: list metadata plus its persisted routing controls. */ export const tourDetailResponseSchema = tourCreateResponseSchema; export type TourDetailResponse = z.infer;