Tours: save a tour as a new, independent tour (P2C-07)

This commit is contained in:
Maurice
2026-10-15 22:18:41 +02:00
parent 356df90095
commit 485e360c64
38 changed files with 432 additions and 62 deletions
+8
View File
@@ -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 {
+11
View File
@@ -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) })))
+2 -1
View File
@@ -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) {}
+15 -2
View File
@@ -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')
+61
View File
@@ -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,
+60
View File
@@ -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) => {
+7 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+8 -2
View File
@@ -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;
+8 -3
View File
@@ -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;
+7 -2
View File
@@ -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;
+8 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+8 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+9 -2
View File
@@ -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;
+8 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+9 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+2 -2
View File
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
const GENERAL_KEY_COUNT = 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);
+7 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+8 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+7 -2
View File
@@ -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);
});
});
+12 -1
View File
@@ -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>;