Compare commits

...

12 Commits

Author SHA1 Message Date
Maurice 35fb59e64e chore(lint): lower the no-misused-promises baseline the packing fix brought down 2026-10-26 22:02:04 +01:00
Maurice ee3fff1dab fix(packing): Enter on the first item keeps the typing going in the add field
A new list starts with a '...' placeholder row. Typing the first item there
and pressing Enter saved it but left the cursor nowhere, while the "Add item"
field puts it straight back for the next item. Enter on a filled placeholder
row now opens the category's add field and focuses it. A blur still just
saves, so clicking elsewhere does not pull the focus back.

Closes #2825
2026-10-26 21:50:37 +01:00
Maurice 3bc75d2b1c fix(airports): keep airports OurAirports flags as having no scheduled service
The airport picker never offered Little Cayman (LYB): OurAirports marks it as
having no scheduled service although it has daily flights, and the build
dropped every airport on that flag. The same flag also hid Vestmannaeyjar,
Western Sydney and around 1,280 other large and medium airports with an IATA
code. The build now applies the flag to small airports only, where it keeps
private airstrips out.

The bundled list gains exactly those airports. Every entry it already carried
stays unchanged, because a fresh OurAirports export currently renames PBI to
DJT and drops nine other codes that saved bookings point at.

Closes #2820
2026-10-26 20:21:13 +01:00
Maurice 094426debb revert(places): keep the Tracks choice out of the pool while Tours is on, as B4A decided 2026-10-26 14:26:09 +01:00
Maurice 313198ba63 fix(places): read the dedup tracks off the dedup inputs, the places files stay within their size entries 2026-10-26 14:18:59 +01:00
Maurice e677d58f91 fix(places): a track is a duplicate only of a track, never of a sight of that name
A GPX or KML import skipped every row whose name was already a place of the
trip. A watch names a hike after where it went, so "Hokitika Gorge" was
refused as already in the trip because the gorge itself was a planned
place, with no track to show for it (#2810). A track now matches only the
tracks of that name; a pin still matches every place of its name.

The Places filter also hid its "Tracks" choice whenever Tours was on, on the
grounds that tracks are tours then. A track imported before Tours is a
plain place and stays in the list, so the choice now counts the tracks that
are no tour.
2026-10-26 14:08:56 +01:00
Maurice bc60aea839 test(studio): the book words read the new heading 2026-10-26 13:29:35 +01:00
Maurice b5cd951bd2 fix(studio): the page of stops without photos or words reads as along the way
Auto layout gathers journal entries with no photo and no text, one after the
other, onto one list page instead of giving each an empty page. Its heading
said "Stations" (German "Stationen"), which read as train stations and gave
no hint why those places were on it. It now says "Along the way" in every
language; each stop still carries its date.
2026-10-26 13:16:35 +01:00
Maurice fd818050bd fix(tours): pair the tracks to take over in the tours domain, off the places list 2026-10-26 12:47:09 +01:00
Maurice 0313597b10 fix(tours): read the tracks to take over through the typed query API, seed the tests through the factories 2026-10-26 12:35:19 +01:00
Maurice 2c867673bf test(tours): count the new preview key, lower the await-in-loop entry, type the adopt rows 2026-10-26 12:23:08 +01:00
Maurice 39ebf0976c fix(tours): a tour import takes over a track imported before Tours
A GPX track imported in 4.x is a plain place: a line, no tours row. It never
showed under Tours, and importing the same file there was refused as a
duplicate by its name, so there was no way to turn it into a tour (#2810).

The tour import now takes over a track of the trip that has the same name
and is no tour yet: the place keeps its name and line and gets the tours
row, with the recorded times the file adds. The preview says so instead of
"Already in the trip". New and taken-over tracks commit in one transaction.
2026-10-26 12:17:15 +01:00
82 changed files with 516 additions and 144 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 382,
"@typescript-eslint/no-misused-promises": 849,
"@typescript-eslint/no-misused-promises": 848,
"no-console": 88,
"complexity": 220,
"@typescript-eslint/no-explicit-any": 146,
@@ -485,6 +485,17 @@ describe('KategorieGruppe — inline add item', () => {
expect(onAddItem).not.toHaveBeenCalled()
})
it('FE-W5CAT-028a: Enter on the first item typed into the placeholder row opens the add field (#2825)', async () => {
server.use(http.put('/api/trips/1/packing/1', () => HttpResponse.json({ item: buildPackingItem({ id: 1, name: 'Tent', category: 'Gear' }) })))
setup({ items: [buildPackingItem({ id: 1, name: '...', category: 'Gear' })] })
fireEvent.click(screen.getByText('...'))
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
fireEvent.keyDown(screen.getByPlaceholderText('...'), { key: 'Enter' })
await waitFor(() => expect(screen.getByPlaceholderText('Item name...')).toHaveFocus())
})
it('FE-W5CAT-028: the confirm button adds the item, and stays inert while empty', () => {
const onAddItem = vi.fn(async () => {})
const { container } = setup({ onAddItem })
@@ -86,6 +86,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
const [showAddItem, setShowAddItem] = useState(false)
const [newItemName, setNewItemName] = useState('')
const addItemRef = useRef<HTMLInputElement>(null)
const openAddItem = () => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }
const menuBtnRef = useRef<HTMLButtonElement>(null)
const assigneeDropdownRef = useRef<HTMLDivElement>(null)
const { togglePackingItem } = useTripStore()
@@ -292,7 +293,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
const contributions = contribFor(item.id)
return (
<React.Fragment key={item.id}>
<ArtikelZeile item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} divider={index > 0}
<ArtikelZeile item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} onPlaceholderFilled={openAddItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} divider={index > 0}
tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave}
drag={canEdit && reorderable ? {
isDragging: dragId === item.id,
@@ -343,7 +344,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
// A row of its own on a faint tint, so it closes the card instead
// of trailing off: a dashed box in the checkbox column (past the drag
// grip's width) and the label where an item's name sits.
<button type="button" onClick={() => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }}
<button type="button" onClick={openAddItem}
style={{ display: 'flex', alignItems: 'center', gap: 9, width: 'calc(100% - 8px)', margin: '6px 4px 0', paddingBlock: 8, paddingInlineEnd: 10, paddingInlineStart: 28, borderRadius: 10, border: 'none', background: 'var(--bg-secondary)', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'start' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
@@ -257,6 +257,23 @@ describe('ArtikelZeile — renaming', () => {
await waitFor(() => expect(screen.getByText('...')).toBeInTheDocument())
})
it('FE-W5ROW-008b: Enter on a filled placeholder row hands the typing on, a blur does not (#2825)', async () => {
server.use(http.put('/api/trips/1/packing/1', () => HttpResponse.json({ item: buildPackingItem({ id: 1, name: 'Tent' }) })))
const onPlaceholderFilled = vi.fn()
setup({ item: buildPackingItem({ id: 1, name: '...', category: 'Gear' }), onPlaceholderFilled })
fireEvent.click(screen.getByText('...'))
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
fireEvent.blur(screen.getByPlaceholderText('...'))
await waitFor(() => expect(screen.queryByPlaceholderText('...')).toBeNull())
expect(onPlaceholderFilled).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('...'))
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
fireEvent.keyDown(screen.getByPlaceholderText('...'), { key: 'Enter' })
await waitFor(() => expect(onPlaceholderFilled).toHaveBeenCalledTimes(1))
})
it('FE-W5ROW-009: a failing rename surfaces a save error', async () => {
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
setup()
@@ -25,6 +25,8 @@ interface ArtikelZeileProps {
categories: string[]
onCategoryChange: () => void
onDelete?: (item: PackingItem) => Promise<void>
/** Enter on the filled '...' row, so the category's add field can take over the typing (#2825). */
onPlaceholderFilled?: () => void
bagTrackingEnabled?: boolean
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
@@ -49,7 +51,7 @@ interface ArtikelZeileProps {
}
}
export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, divider = false, drag }: ArtikelZeileProps) {
export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, onPlaceholderFilled, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, divider = false, drag }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
@@ -80,9 +82,12 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleSaveName = async () => {
const handleSaveName = async (viaEnter = false) => {
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
try {
await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false)
if (viaEnter && isPlaceholder) onPlaceholderFilled?.()
}
catch { toast.error(t('packing.toast.saveError')) }
}
@@ -188,8 +193,8 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
type="text" value={editName} autoFocus
placeholder={isPlaceholder ? '...' : undefined}
onChange={e => setEditName(e.target.value)}
onBlur={handleSaveName}
onKeyDown={e => { if (e.key === 'Enter') void handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
onBlur={() => void handleSaveName()}
onKeyDown={e => { if (e.key === 'Enter') void handleSaveName(true); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
/>
) : canEdit ? (
@@ -1,4 +1,4 @@
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-003
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-004
// The GPX preview as ACT-01 uses it: preset to the tour's type, a city walk
// imported as one, and when a track was recorded shown where it carried times.
import { fireEvent, render, screen } from '@testing-library/react';
@@ -47,6 +47,13 @@ describe('TourGpxPreviewDialog (ACT-01)', () => {
expect(recorded[0]).toHaveTextContent('50 min');
});
it('FE-GPX-PREVIEW-004: a track taken over from before Tours reads as one, not as a duplicate (#2810)', () => {
render(<TourGpxPreviewDialog gpxImport={gpxImport([track({ adopts_track: true })])} />);
expect(screen.getByTestId('gpx-preview-track')).not.toHaveAttribute('data-duplicate');
expect(screen.getByText(/tours\.import\.previewAdopts|becomes a tour/)).toBeInTheDocument();
expect(screen.queryByText(/tours\.import\.previewDuplicate|Already in the trip/)).toBeNull();
});
it('FE-GPX-PREVIEW-002: imports as the preset type, a paddle tour with its boat and water', () => {
const current = gpxImport([track()]);
render(
@@ -79,6 +79,7 @@ function PreviewTrackRow({
{track.name || name}
</span>
{track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>}
{track.adopts_track && <SoftPill>{t('tours.import.previewAdopts')}</SoftPill>}
</div>
<div className="flex flex-wrap items-center gap-1.5 ps-8" style={fs(11)}>
<span className={BADGE}>{formatDistance(track.distance_km, unit)}</span>
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
// Build server/assets/airports.json from OurAirports (davidmegginson.github.io/ourairports-data).
// License: Public Domain. Keeps large/medium airports with an IATA code; timezone derived from coords via tz-lookup.
// License: Public Domain. Keeps large/medium airports with an IATA code, small ones only with scheduled service;
// timezone derived from coords via tz-lookup.
import fs from 'node:fs';
import https from 'node:https';
import path from 'node:path';
@@ -91,7 +92,9 @@ for (let i = 1; i < rows.length; i++) {
if (!KEEP.has(r[TYPE])) continue;
const iata = r[IATA]?.trim().toUpperCase();
if (!iata || iata.length !== 3) continue;
if (r[SERVICE] !== 'yes') continue;
// The scheduled_service flag lags reality: Little Cayman (LYB) has daily flights and is
// marked "no". So it only filters the small fields, where it keeps every airstrip with a code out.
if (r[TYPE] === 'small_airport' && r[SERVICE] !== 'yes') continue;
const lat = Number(r[LAT]);
const lng = Number(r[LNG]);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
+1 -1
View File
@@ -5,7 +5,7 @@
"@typescript-eslint/no-require-imports": 18,
"@typescript-eslint/no-unsafe-function-type": 1,
"@typescript-eslint/no-unused-vars": 36,
"no-await-in-loop": 563,
"no-await-in-loop": 562,
"no-console": 235,
"no-empty": 6
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"scripts/db-call-graph.mjs": 1036,
"scripts/generate-entities.ts": 2259,
"src/db/repositories/Places.repository.ts": 1439,
"src/db/repositories/Places.repository.ts": 1438,
"src/db/repositories/Reservations.repository.ts": 1604,
"src/db/repositories/Trips.repository.ts": 1121,
"src/db/repositories/Users.repository.ts": 1445,
@@ -605,16 +605,15 @@ export class PlacesRepository extends TrekRepository<Places> {
* it was.
*/
async listDedupInputs(trip_id: string): Promise<{
name: string | null; lat: number | null; lng: number | null;
name: string | null; lat: number | null; lng: number | null; route_geometry: string | null;
google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null;
}[]> {
// route_geometry tells a track from a pin: a track to import matches only tracks (#2810).
return this.qb('p')
.select(['p.name', 'p.lat', 'p.lng', 'p.google_place_id', 'p.google_ftid', 'p.osm_id', 'p.amap_poi_id'])
.select(['p.name', 'p.lat', 'p.lng', 'p.route_geometry',
'p.google_place_id', 'p.google_ftid', 'p.osm_id', 'p.amap_poi_id'])
.where('p.trip_id = ?', [trip_id])
.execute<{
name: string | null; lat: number | null; lng: number | null;
google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null;
}[]>('all', false);
.execute<Awaited<ReturnType<PlacesRepository['listDedupInputs']>>>('all', false);
}
/**
+17 -5
View File
@@ -86,11 +86,16 @@ export async function reclaimPhotoCache(
export interface DedupSet {
names: Set<string>;
/** The names of the places with a line: a track to import matches only these (#2810). Absent where none is read. */
trackNames?: Set<string>;
coords: Array<{ lat: number; lng: number }>;
/** Provider ids (google_place_id, google_ftid, osm_id, amap_poi_id) of the places already in the trip. */
externalIds: Set<string>;
}
/** A candidate for the dedup set; `track` when it is a line (a GPX track or route, a KML path). */
export type DedupCandidate = PlaceMatchCandidate & { track?: boolean };
/**
* Returns true if a candidate place is already represented in the dedup set.
*
@@ -104,12 +109,14 @@ export interface DedupSet {
* JavaScript's is not, and only unnamed rows contribute coordinates to a dedup
* set. `findDuplicatePlace` spells out where the two still answer differently.
*/
export function isPlaceDuplicate(candidate: PlaceMatchCandidate, dedup: DedupSet): boolean {
export function isPlaceDuplicate(candidate: DedupCandidate, dedup: DedupSet): boolean {
for (const strategy of placeMatchStrategies(candidate)) {
if (strategy.by === 'externalId') {
if (dedup.externalIds.has(strategy.id)) return true;
} else if (strategy.by === 'name') {
if (dedup.names.has(strategy.name)) return true;
// A track is the same as a track of that name, never as a sight of that name:
// a watch names a hike after the place it went to (#2810).
if ((candidate.track ? dedup.trackNames : dedup.names)?.has(strategy.name)) return true;
} else if (
dedup.coords.some(
(c) =>
@@ -130,29 +137,34 @@ export function isPlaceDuplicate(candidate: PlaceMatchCandidate, dedup: DedupSet
*/
export function dedupSetOf(
rows: ReadonlyArray<
{ name: string | null; lat: number | null; lng: number | null } & Parameters<typeof externalIdsOf>[0]
{ name: string | null; lat: number | null; lng: number | null; route_geometry?: string | null } & Parameters<
typeof externalIdsOf
>[0]
>,
): DedupSet {
const names = new Set<string>();
const trackNames = new Set<string>();
const coords: Array<{ lat: number; lng: number }> = [];
const externalIds = new Set<string>();
for (const row of rows) {
for (const id of externalIdsOf(row)) externalIds.add(id);
if (row.name) {
names.add(row.name.trim().toLowerCase());
if (row.route_geometry) trackNames.add(row.name.trim().toLowerCase());
} else if (row.lat != null && row.lng != null) {
coords.push({ lat: row.lat, lng: row.lng });
}
}
return { names, coords, externalIds };
return { names, trackNames, coords, externalIds };
}
/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */
export function trackInsertedInDedupSet(place: PlaceMatchCandidate, dedup: DedupSet): void {
export function trackInsertedInDedupSet(place: DedupCandidate, dedup: DedupSet): void {
for (const id of externalIdsOf(place)) dedup.externalIds.add(id);
const normalizedName = normalizePlaceName(place.name);
if (normalizedName) {
dedup.names.add(normalizedName);
if (place.track) (dedup.trackNames ??= new Set()).add(normalizedName);
} else if (place.lat != null && place.lng != null) {
dedup.coords.push({ lat: place.lat, lng: place.lng });
}
+5 -5
View File
@@ -758,7 +758,7 @@ export class PlacesService {
async gpxDuplicates(tripId: string, rows: PreparedGpxPlace[]): Promise<boolean[]> {
const dedup = await this.buildDedupSet(tripId);
return rows.map((wp) => {
const candidate = { name: wp.name, lat: wp.lat, lng: wp.lng };
const candidate = { name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry };
if (isPlaceDuplicate(candidate, dedup)) return true;
trackInsertedInDedupSet(candidate, dedup);
return false;
@@ -925,7 +925,7 @@ export class PlacesService {
// `findWithTagsAndRatings` INSIDE the caller's transaction, so the read
// sees the still-uncommitted row.
for (const wp of waypoints) {
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry }, dedup)) {
skipped++;
continue;
}
@@ -960,7 +960,7 @@ export class PlacesService {
});
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
created.push(place);
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry }, dedup);
}
return { places: created, count: created.length, skipped };
@@ -1033,7 +1033,7 @@ export class PlacesService {
const fallbackName = `Placemark ${fallbackIndex}`;
const name = parsedPlacemark.name || fallbackName;
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup)) {
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath }, dedup)) {
summary.skippedCount += 1;
dupCount++;
fallbackIndex += 1;
@@ -1079,7 +1079,7 @@ export class PlacesService {
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
created.push(place);
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup);
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath }, dedup);
summary.createdCount += 1;
fallbackIndex += 1;
}
@@ -70,7 +70,10 @@ export function activityFacts(
}
/** The times an import stores for a track, as the DATETIME columns hold them; NULL without. */
function storedTimes(row: PreparedGpxPlace | undefined): { started: DbTimestamp | null; ended: DbTimestamp | null } {
export function recordedTimesOf(row: PreparedGpxPlace | undefined): {
started: DbTimestamp | null;
ended: DbTimestamp | null;
} {
const times = row?.trackTimes;
return times
? { started: dbNow(new Date(times.startedAt)), ended: dbNow(new Date(times.endedAt)) }
@@ -93,7 +96,7 @@ export function trackTimesFor(
(row, at) => !taken.has(at) && row.name === place.name && row.lat === place.lat && row.lng === place.lng,
);
if (index >= 0) taken.add(index);
byPlace.set(place.id, storedTimes(rows[index]));
byPlace.set(place.id, recordedTimesOf(rows[index]));
}
return byPlace;
}
@@ -0,0 +1,42 @@
import { normalizePlaceName } from '@trek/shared';
/** A place of the trip as the places list reads it: enough to tell a track that is no tour yet. */
export interface TripPlaceForAdoption {
id: number;
name: string;
route_geometry?: string | null;
tour_place_id?: number | null;
}
/** A track of the trip a file row takes over: its place and the line it already holds. */
export interface AdoptedTrack {
id: number;
route_geometry: string;
}
/**
* The rows of a tour import that take over a track already in the trip, by
* row index: a place with a line and no tour yet, under the same name
* (trimmed, any case, the duplicate rule's own reading), one place per row
* and oldest first. A GPX file imported before Tours existed (4.x) left its
* tracks as plain places, and the name alone made every tour import of the
* same file a duplicate, with no tour to show for it (#2810).
*/
export function tracksToAdopt(
rows: ReadonlyArray<{ name: string }>,
places: ReadonlyArray<TripPlaceForAdoption>,
): Map<number, AdoptedTrack> {
const byName = new Map<string, AdoptedTrack[]>();
for (const place of [...places].sort((a, b) => a.id - b.id)) {
const key = normalizePlaceName(place.name);
if (!key || !place.route_geometry || place.tour_place_id != null) continue;
byName.set(key, [...(byName.get(key) ?? []), { id: place.id, route_geometry: place.route_geometry }]);
}
const adopt = new Map<number, AdoptedTrack>();
rows.forEach((row, index) => {
const key = normalizePlaceName(row.name);
const track = key ? byName.get(key)?.shift() : undefined;
if (track) adopt.set(index, track);
});
return adopt;
}
+140 -75
View File
@@ -16,7 +16,7 @@ import { toRowId } from '../common/row-id';
import { UnitOfWork } from '../database/unit-of-work';
import { PlaceImportService } from '../place-import/place-import.service';
import { PlacesService } from '../places/places.service';
import { toActivity, trackTimesFor } from './tour-activities.helpers';
import { recordedTimesOf, toActivity, trackTimesFor } from './tour-activities.helpers';
import {
copiedFacet,
copyStart,
@@ -29,6 +29,7 @@ import { readTourFile } from './tour-file-import.helpers';
import { provenanceSource, tourItemOf } from './tour-item.helpers';
import { TourPaddleCheckService } from './tour-paddle-check.service';
import { advanceTourRevision } from './tour-revision';
import { tracksToAdopt } from './tour-track-adoption';
import { toVisit } from './tour-visits.service';
import { checkTourCommand, firstPointOf, importFileName, keyedWaypoints, tourRouteFields } from './tour-write.helpers';
import { computeTourMetrics, localHashOf, parseRouteGeometry, type GeometryPoint } from './tours.helpers';
@@ -58,6 +59,30 @@ import {
wandererDifficultyToSac,
} from '@trek/shared';
/** `fn` over `items` one after the other, in order: the writes of one import share a transaction. */
async function inOrder<T, R>(items: readonly T[], fn: (item: T) => Promise<R>): Promise<R[]> {
return items.reduce<Promise<R[]>>(async (done, item) => [...(await done), await fn(item)], Promise.resolve([]));
}
/**
* What the type adds to each tour a file import makes: a bike tour and a run
* their profile, a paddle tour its boat and water, a ski track its discipline
* and technique (SKI-02). A run comes in without a pace and a ski track
* without a time: neither is worked out for them, so they stay unknown.
*/
function importedTypeFields(fields: TourGpxImportFields) {
return {
bike_profile: fields.bike_profile ?? null,
run_profile: fields.run_profile ?? null,
run_pace_seconds_per_km: null,
ski_discipline: fields.ski_discipline ?? null,
ski_technique: fields.ski_technique ?? null,
boat_type: fields.boat_type ?? null,
water_context: fields.water_context ?? null,
paddle_speed_kmh: null,
};
}
export interface ImportGpxAsTourResult {
tours: TourListItem[];
caution: boolean;
@@ -783,88 +808,45 @@ export class ToursService {
const { rows } = await readTourFile(this.placeImport, fileBuffer, fileName);
if (rows.length === 0) return null;
await this.assertTourTypeEnabled(fields.tour_type);
// What the type adds to each imported tour: a bike tour and a run their profile, a paddle tour its boat and water,
// a ski track its discipline and technique (SKI-02). A run comes in without a pace and a ski track without a
// time: neither is worked out for them, so they stay unknown.
const typeFields = {
bike_profile: fields.bike_profile ?? null,
run_profile: fields.run_profile ?? null,
run_pace_seconds_per_km: null,
ski_discipline: fields.ski_discipline ?? null,
ski_technique: fields.ski_technique ?? null,
boat_type: fields.boat_type ?? null,
water_context: fields.water_context ?? null,
paddle_speed_kmh: null,
};
const typeFields = importedTypeFields(fields);
const adopt = await this.adoptableTracks(tripId, rows);
const fresh = rows.filter((_, index) => !adopt.has(index));
const tours: TourListItem[] = [];
const adopted: number[] = [];
const result = await this.uow.transactional(async () => {
// A nested transactional, so the places and their facets commit together.
const imported = await this.places.importPreparedGpx(tripId, rows);
const times = trackTimesFor(rows, imported.places);
for (const place of imported.places) {
const recorded = times.get(place.id);
const metrics = computeTourMetrics(parseRouteGeometry(place.route_geometry));
const matchConfidence = metrics.hasElevation ? 1 : 0.3;
await this.toursRepo.insertTour({
place_id: place.id,
tour_type: fields.tour_type,
...typeFields,
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
match_confidence: matchConfidence,
max_hiking_difficulty: null,
origin: 'gpx',
origin_file_name: importFileName(fileName),
track_started_at: recorded?.started ?? null,
track_ended_at: recorded?.ended ?? null,
});
tours.push(
tourItemOf({
place_id: place.id,
name: place.name,
description: place.description ?? null,
website: place.website ?? null,
tour_type: fields.tour_type,
...typeFields,
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: matchConfidence,
max_hiking_difficulty: null,
derived_from_place_id: null,
origin: 'gpx',
portage_speed_kmh: null,
paddle_lake_style: null,
track_marks: null,
has_recorded_track: 0,
planned: 0,
has_waypoints: 0,
stage_ends: 0,
route_missing: 0,
revision: 1,
hiking_preferences: null,
track_started_at: recorded?.started ?? null,
track_ended_at: recorded?.ended ?? null,
}),
);
}
const imported =
fresh.length > 0 ? await this.places.importPreparedGpx(tripId, fresh) : { places: [], count: 0, skipped: 0 };
const times = trackTimesFor(fresh, imported.places);
tours.push(
...(await inOrder(imported.places, (place) =>
this.insertImportedTour(
place,
place.route_geometry ?? null,
times.get(place.id),
fields,
typeFields,
fileName,
),
)),
);
// A track from before Tours keeps its place and its line and becomes the
// tour; the file adds the recorded times 4.x never stored.
await inOrder([...adopt], async ([index, track]) => {
const place = await this.places.get(tripId, String(track.id));
if (!place) return;
const recorded = recordedTimesOf(rows[index]);
tours.push(await this.insertImportedTour(place, track.route_geometry, recorded, fields, typeFields, fileName));
adopted.push(place.id);
});
return imported;
});
if (result.places.length === 0) return { tours: [], caution: false, skipped: result.skipped };
if (tours.length === 0) return { tours: [], caution: false, skipped: result.skipped };
this.broadcastToursChanged(
tripId,
result.places.map((place) => place.id),
tours.map((tour) => tour.place_id),
socketId,
);
for (const place of result.places) {
@@ -874,9 +856,90 @@ export class ToursService {
this.logger.warn(`Committed GPX place ${place.id}: realtime notification failed`);
}
}
// The taken-over places now read as tours; the other clients get the row again.
await inOrder(adopted, async (id) => {
try {
const place = await this.places.get(tripId, String(id));
if (place) this.places.broadcast(tripId, 'place:updated', { place }, socketId);
} catch {
this.logger.warn(`Committed GPX tour on place ${id}: realtime notification failed`);
}
});
return { tours, caution: tours.some((t) => t.caution), skipped: result.skipped };
}
/** Which rows of a file take over a track of the trip from before Tours (#2810), read off the places list. */
private async adoptableTracks(tripId: string, rows: ReadonlyArray<{ name: string }>) {
return tracksToAdopt(rows, await this.places.list(tripId, {}));
}
/**
* The `tours` row for one place of a GPX import, with its metrics read off
* `geometry` and the recorded times, and the list item the import answers
* with. A new place and a track taken over from before Tours (#2810) get the
* same row.
*/
private async insertImportedTour(
place: { id: number; name: string; description?: string | null; website?: string | null },
geometry: string | null,
recorded: ReturnType<typeof recordedTimesOf> | undefined,
fields: TourGpxImportFields,
typeFields: ReturnType<typeof importedTypeFields>,
fileName: string | undefined,
): Promise<TourListItem> {
const metrics = computeTourMetrics(parseRouteGeometry(geometry));
const matchConfidence = metrics.hasElevation ? 1 : 0.3;
await this.toursRepo.insertTour({
place_id: place.id,
tour_type: fields.tour_type,
...typeFields,
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
match_confidence: matchConfidence,
max_hiking_difficulty: null,
origin: 'gpx',
origin_file_name: importFileName(fileName),
track_started_at: recorded?.started ?? null,
track_ended_at: recorded?.ended ?? null,
});
return tourItemOf({
place_id: place.id,
name: place.name,
description: place.description ?? null,
website: place.website ?? null,
tour_type: fields.tour_type,
...typeFields,
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: matchConfidence,
max_hiking_difficulty: null,
derived_from_place_id: null,
origin: 'gpx',
portage_speed_kmh: null,
paddle_lake_style: null,
track_marks: null,
has_recorded_track: 0,
planned: 0,
has_waypoints: 0,
stage_ends: 0,
route_missing: 0,
revision: 1,
hiking_preferences: null,
track_started_at: recorded?.started ?? null,
track_ended_at: recorded?.ended ?? null,
});
}
/**
* PAD-02: what a file would import, read with the import's own parser and
* duplicate rule and written nowhere. Null when the file holds no track or
@@ -887,6 +950,7 @@ export class ToursService {
const { rows, leftOut } = await readTourFile(this.placeImport, fileBuffer, fileName);
if (rows.length === 0) return null;
const duplicates = await this.places.gpxDuplicates(tripId, rows);
const adopt = await this.adoptableTracks(tripId, rows);
return {
tracks: rows.map((row, index) => {
const geometry = parseRouteGeometry(row.routeGeometry);
@@ -900,7 +964,8 @@ export class ToursService {
elevation_loss_m: metrics.elevationLossM,
started_at: row.trackTimes?.startedAt ?? null,
ended_at: row.trackTimes?.endedAt ?? null,
duplicate: duplicates[index] ?? false,
duplicate: !adopt.has(index) && (duplicates[index] ?? false),
...(adopt.has(index) ? { adopts_track: true } : {}),
};
}),
...(leftOut ? { left_out: leftOut } : {}),
+45
View File
@@ -535,6 +535,51 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
expect(await countRows(orm, Tours)).toBe(1);
});
it('takes over a track from before Tours with the same name instead of skipping it (#2810)', async () => {
// 4.x stored a GPX track as a plain place: a line, no tours row.
const line = '[[49,12,100],[49.01,12.01,150],[49.02,12.02,120]]';
const legacy = (await makePlace(orm, 5, { name: 'Alpine Loop ', lat: 49, lng: 12, route_geometry: line })).id;
const elsewhere = (await makePlace(orm, 6, { name: 'Alpine loop', lat: 49, lng: 12, route_geometry: line })).id;
const preview = await previewGpx(OWNER, TRACK_GPX);
expect(preview.status).toBe(200);
expect(preview.body.tracks[0]).toMatchObject({ duplicate: false, adopts_track: true });
expect(await countRows(orm, Tours)).toBe(0);
const res = await importGpx(OWNER, TRACK_GPX);
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ caution: false, skipped: 0 });
expect(res.body.tours.map((tour: { place_id: number }) => tour.place_id)).toEqual([legacy]);
expect(await countRows(orm, Places, { trip: 5 })).toBe(1);
expect(await placeColumns(legacy, 'name', 'route_geometry')).toEqual({
name: 'Alpine Loop ',
route_geometry: line,
});
expect(await findRow(orm, Tours, { place: legacy })).toMatchObject({ tour_type: 'hike', origin: 'gpx' });
expect(await findRow(orm, Tours, { place: elsewhere })).toBeNull();
expect(broadcast.mock.calls.map((c) => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:updated']));
expect(broadcast.mock.calls.map((c) => c[1])).not.toContain('place:created');
// Now a tour, the same file is a duplicate again.
expect((await previewGpx(OWNER, TRACK_GPX)).body.tracks[0]).toMatchObject({ duplicate: true });
expect((await importGpx(OWNER, TRACK_GPX)).body).toEqual({ tours: [], caution: false, skipped: 1 });
});
it('imports a hike named after a sight of the trip instead of skipping it as a duplicate (#2810)', async () => {
// A watch names the hike after where it went, and that place is already planned.
const sight = (await makePlace(orm, 5, { name: 'alpine loop', lat: 47, lng: 11 })).id;
const preview = await previewGpx(OWNER, TRACK_GPX);
expect(preview.body.tracks[0]).toMatchObject({ name: 'Alpine loop', duplicate: false });
const res = await importGpx(OWNER, TRACK_GPX);
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ skipped: 0 });
expect(res.body.tours).toHaveLength(1);
expect(res.body.tours[0].place_id).not.toBe(sight);
expect(await placeColumns(sight, 'route_geometry')).toEqual({ route_geometry: null });
});
it('400 without a file, and for a file with no track or route', async () => {
const missing = await importGpx(OWNER, null);
expect(missing.status).toBe(400);
@@ -574,20 +574,21 @@ describe('PlacesRepository.listForGpx (PL29) and existsByImageUrl (PI1)', () =>
* by, the service-level dedup tests.
*/
describe('PlacesRepository.listDedupInputs (PL24)', () => {
it('PLACEREPO-048: projects exactly the seven dedup columns, scoped to the trip', async () => {
it('PLACEREPO-048: projects exactly the eight dedup columns, the line included (#2810), scoped to the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
await insertRow(t.orm, Places, {
trip: trip.id, name: 'Louvre', lat: 48.86, lng: 2.34, google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1',
route_geometry: '[[48.86,2.34],[48.87,2.35]]',
});
await insertRow(t.orm, Places, { trip: trip.id, name: '', lat: 1.1, lng: 2.2 });
createPlace(testDb, other.id, { name: 'Not this trip' });
const rows = await places.listDedupInputs(String(trip.id));
expect(rows).toEqual([
{ name: 'Louvre', lat: 48.86, lng: 2.34, google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1' },
{ name: '', lat: 1.1, lng: 2.2, google_place_id: null, google_ftid: null, osm_id: null, amap_poi_id: null },
{ name: 'Louvre', lat: 48.86, lng: 2.34, route_geometry: '[[48.86,2.34],[48.87,2.35]]', google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1' },
{ name: '', lat: 1.1, lng: 2.2, route_geometry: null, google_place_id: null, google_ftid: null, osm_id: null, amap_poi_id: null },
]);
});
@@ -0,0 +1,23 @@
/**
* The bundled assets/airports.json itself, not a fixture.
*
* OurAirports marks some airports with daily flights as having no scheduled
* service, and the build used to drop them on that flag alone (#2820, Little
* Cayman). These pin a few of them and the codes saved bookings already point at.
*/
import { findByIata } from '../../../../src/nest/airports/airports.data';
import { describe, it, expect } from 'vitest';
describe('bundled airport dataset', () => {
it('AIRPORT-ASSET-001: keeps airports OurAirports flags as unscheduled', () => {
expect(findByIata('LYB')?.name).toBe('Edward Bodden Little Cayman Airfield');
expect(findByIata('VEY')?.country).toBe('IS');
});
it('AIRPORT-ASSET-002: still resolves the codes it carried before', () => {
for (const code of ['PBI', 'ADA', 'GCM', 'CYB', 'FRA']) {
expect(findByIata(code), code).not.toBeNull();
}
});
});
@@ -247,7 +247,7 @@ describe('what the engine reads', () => {
expect(travel.entries.map((e) => [e.id, e.title, e.mood])).toEqual([[entry.id, 'Alfama', 'amazing']]);
expect(travel.locale).toBe('de-DE');
expect(travel.labels).toMatchObject({ day: 'TAG {day}', stations: 'Stationen', pros: 'Pro', cons: 'Contra' });
expect(travel.labels).toMatchObject({ day: 'TAG {day}', stations: 'Unterwegs', pros: 'Pro', cons: 'Contra' });
expect(travel.path).toEqual([]);
expect((await edit.wordsFor(owner.id)).locale).toBe('de-DE');
});
@@ -21,7 +21,7 @@ describe('the book words', () => {
expect(french.locale).toBe('fr-FR');
expect(french.labels).toMatchObject({
day: 'JOUR {day}',
stations: 'Étapes',
stations: 'En chemin',
summary: 'Résumé',
countries: 'Pays',
});
@@ -30,7 +30,7 @@ describe('the book words', () => {
locale: 'en-US',
labels: {
day: 'DAY {day}',
stations: 'Stations',
stations: 'Along the way',
summary: 'Summary',
countries: 'Countries',
distance: 'Distance',
@@ -5,6 +5,7 @@
*/
import {
COORD_DEDUP_TOLERANCE,
dedupSetOf,
externalIdsOf,
isPlaceDuplicate,
mapWithConcurrency,
@@ -25,6 +26,29 @@ vi.mock('../../../src/db/database', () => ({
const emptyDedup = (): DedupSet => ({ names: new Set(), coords: [], externalIds: new Set() });
describe('isPlaceDuplicate / trackInsertedInDedupSet', () => {
it('matches a track only against the tracks of that name, never a sight of that name (#2810)', () => {
// The watch named the hike after the gorge, and the gorge is already a place of the trip.
const dedup = dedupSetOf([
{ name: 'Hokitika Gorge', lat: -42.95, lng: 171.0, route_geometry: null },
{ name: ' 008 ', lat: 1, lng: 2, route_geometry: '[[1,2],[3,4]]' },
]);
expect(isPlaceDuplicate({ name: 'hokitika gorge', lat: -42.9, lng: 171.0, track: true }, dedup)).toBe(false);
expect(isPlaceDuplicate({ name: '008', lat: 1, lng: 2, track: true }, dedup)).toBe(true);
// A pin still matches every place of its name, a track included.
expect(isPlaceDuplicate({ name: 'Hokitika Gorge', lat: null, lng: null }, dedup)).toBe(true);
expect(isPlaceDuplicate({ name: '008', lat: null, lng: null }, dedup)).toBe(true);
// A track imported earlier in the same file counts for the next one.
trackInsertedInDedupSet({ name: 'Hokitika Gorge', lat: -42.9, lng: 171.0, track: true }, dedup);
expect(isPlaceDuplicate({ name: 'Hokitika gorge ', lat: null, lng: null, track: true }, dedup)).toBe(true);
// A set built by hand without tracks matches no track by name.
const bare = emptyDedup();
trackInsertedInDedupSet({ name: 'Ridge', lat: 1, lng: 2 }, bare);
expect(isPlaceDuplicate({ name: 'Ridge', lat: 1, lng: 2, track: true }, bare)).toBe(false);
trackInsertedInDedupSet({ name: 'Lake', lat: 1, lng: 2, track: true }, bare);
expect([...(bare.trackNames ?? [])]).toEqual(['lake']);
});
it('matches a named place case- and whitespace-insensitively', () => {
const dedup = emptyDedup();
trackInsertedInDedupSet({ name: ' Eiffel Tower ', lat: 1, lng: 2 }, dedup);
@@ -0,0 +1,47 @@
/**
* tracksToAdopt (#2810): which rows of a tour import take over a track that a
* GPX import before Tours left as a plain place.
*/
import { tracksToAdopt } from '../../../src/nest/tours/tour-track-adoption';
import { describe, expect, it } from 'vitest';
const LINE = '[[46.1,11.1,1200],[46.2,11.2,1300]]';
const track = (
id: number,
name: string,
extra: { route_geometry?: string | null; tour_place_id?: number | null } = {},
) => ({
id,
name,
route_geometry: LINE,
tour_place_id: null,
...extra,
});
const rows = (...names: string[]) => names.map((name) => ({ name }));
describe('tracksToAdopt', () => {
it('TRACK-ADOPT-001: pairs a row with a track of the same name, trimmed and in any case', () => {
expect([...tracksToAdopt(rows('GARMIN HIKE'), [track(4, ' Garmin hike ')])]).toEqual([
[0, { id: 4, route_geometry: LINE }],
]);
});
it('TRACK-ADOPT-002: gives each track to one row only, oldest first', () => {
const adopt = tracksToAdopt(rows('Hike', 'hike', 'HIKE'), [track(9, 'hike'), track(3, 'Hike')]);
expect([...adopt]).toEqual([
[0, { id: 3, route_geometry: LINE }],
[1, { id: 9, route_geometry: LINE }],
]);
});
it('TRACK-ADOPT-003: leaves out a tour, a place without a line, an unnamed place and an unnamed row', () => {
const places = [
track(1, 'Ridge', { tour_place_id: 1 }),
track(2, 'Cafe', { route_geometry: null }),
track(3, ' '),
track(4, 'Lake'),
];
expect([...tracksToAdopt(rows('Ridge', 'Cafe', '', 'Other'), places)]).toEqual([]);
});
});
@@ -27,8 +27,8 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { ToursService } from '../../../src/nest/tours/tours.service';
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
import { accommodationsOver } from '../../helpers/accommodations-service';
import { createTrip, createUser } from '../../helpers/factories';
import { countRows, findRows } from '../../helpers/factories/rows';
import { createPlace, createTrip, createUser } from '../../helpers/factories';
import { countRows, findRows, updateRows } from '../../helpers/factories/rows';
import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
import { tourStageDays } from '../../helpers/fake-tour-stage-days';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
@@ -86,7 +86,7 @@ const mixedGpx = Buffer.from(`<gpx>
</trkseg><trkseg><trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt></trkseg></trk>
</gpx>`);
const TRIGGERS = ['fail_facet', 'fail_place', 'fail_color'];
const TRIGGERS = ['fail_facet', 'fail_place', 'fail_color', 'fail_adopt'];
/** The collaborator set the container hands PlacesService (the places.service.test.ts builder). */
async function makePlacesService(): Promise<PlacesService> {
@@ -314,6 +314,21 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
await expectEmpty();
});
it('rolls the new places back too when taking over a track from before Tours fails (#2810)', async () => {
const legacy = createPlace(testDb, Number(tripId), { name: 'Ridge' }).id;
await updateRows(
await sharedTestOrm(testDb),
Places,
{ id: legacy },
{ route_geometry: '[[49,12,100],[49.01,12.01,150]]' },
);
testDb.exec(`CREATE TRIGGER fail_adopt BEFORE INSERT ON tours WHEN NEW.place_id = ${legacy}
BEGIN SELECT RAISE(ABORT, 'adopt failure'); END;`);
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('adopt failure');
expect(await Promise.all([count('places'), count('tours')])).toEqual([1, 0]);
expect(broadcast).not.toHaveBeenCalled();
});
it('rolls back carriers and earlier colors when later coloring fails', async () => {
testDb.exec(`CREATE TRIGGER fail_color BEFORE UPDATE OF route_color ON places WHEN NEW.name = 'Ridge'
BEGIN SELECT RAISE(ABORT, 'color failure'); END;`);
+1 -1
View File
@@ -321,7 +321,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'تخطيط تلقائي',
'journey.studio.export': 'تصدير',
'journey.studio.day': 'اليوم {day}',
'journey.studio.stations': 'المحطات',
'journey.studio.stations': 'على الطريق',
'journey.studio.peersHere': 'هنا',
'journey.studio.folioAuto': 'تلقائي',
'journey.studio.exportLayout': 'التخطيط',
+1
View File
@@ -969,6 +969,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'لم يُعثر على ملف KML في {name}',
'tours.import.archiveTooLargeIn': '{name} كبير جدًا بعد فك الضغط',
'tours.import.previewDuplicate': 'موجود بالفعل في الرحلة',
'tours.import.previewAdopts': 'مسار موجود في الرحلة، سيصبح جولة',
'tours.import.previewAllDuplicates': 'كل المسارات موجودة بالفعل في الرحلة، فلا شيء للاستيراد.',
'tours.import.previewAs': 'الاستيراد كـ',
'tours.import.previewConfirm': 'استيراد {count} جولة',
+1 -1
View File
@@ -323,7 +323,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Avtomatik tərtibat',
'journey.studio.export': 'İxrac et',
'journey.studio.day': 'GÜN {day}',
'journey.studio.stations': 'Dayanacaqlar',
'journey.studio.stations': 'Yol boyunca',
'journey.studio.peersHere': 'burada',
'journey.studio.folioAuto': 'Avtomatik',
+1
View File
@@ -958,6 +958,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': '{name} içində KML faylı tapılmadı',
'tours.import.archiveTooLargeIn': '{name} açıldıqdan sonra çox böyükdür',
'tours.import.previewDuplicate': 'Artıq səyahətdədir',
'tours.import.previewAdopts': 'Artıq səyahətdə trek kimi var, tura çevriləcək',
'tours.import.previewAllDuplicates': 'Bütün treklər artıq səyahətdədir, idxal ediləcək heç nə yoxdur.',
'tours.import.previewAs': 'Bu kimi idxal et',
'tours.import.previewConfirm': '{count} tur idxal et',
+1 -1
View File
@@ -300,7 +300,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Layout automático',
'journey.studio.export': 'Exportar',
'journey.studio.day': 'DIA {day}',
'journey.studio.stations': 'Etapas',
'journey.studio.stations': 'Pelo caminho',
'journey.studio.peersHere': 'aqui',
'journey.studio.folioAuto': 'Automático',
'journey.studio.exportLayout': 'Layout', // same-as-en
+1
View File
@@ -964,6 +964,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Nenhum arquivo KML encontrado em {name}',
'tours.import.archiveTooLargeIn': '{name} é grande demais depois de descompactado',
'tours.import.previewDuplicate': 'Já está na viagem',
'tours.import.previewAdopts': 'Já é uma trilha na viagem, vira um passeio',
'tours.import.previewAllDuplicates': 'Todas as trilhas já estão na viagem, não há nada para importar.',
'tours.import.previewAs': 'Importar como',
'tours.import.previewConfirm': 'Importar {count} passeios',
+1 -1
View File
@@ -298,7 +298,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Disseny automàtic',
'journey.studio.export': 'Exporta',
'journey.studio.day': 'DIA {day}',
'journey.studio.stations': 'Etapes',
'journey.studio.stations': 'Pel camí',
'journey.studio.peersHere': 'aquí',
'journey.studio.folioAuto': 'Automàtic',
'journey.studio.exportLayout': 'Disposició',
+1
View File
@@ -966,6 +966,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': "No s'ha trobat cap fitxer KML a {name}",
'tours.import.archiveTooLargeIn': '{name} és massa gran un cop descomprimit',
'tours.import.previewDuplicate': 'Ja és al viatge',
'tours.import.previewAdopts': 'Ja és una traça al viatge, es converteix en excursió',
'tours.import.previewAllDuplicates': 'Tots els tracks ja són al viatge, no hi ha res a importar.',
'tours.import.previewAs': 'Importa com a',
'tours.import.previewConfirm': 'Importa {count} rutes',
+1 -1
View File
@@ -304,7 +304,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Automatické rozvržení',
'journey.studio.export': 'Exportovat',
'journey.studio.day': 'DEN {day}',
'journey.studio.stations': 'Zastávky',
'journey.studio.stations': 'Cestou',
'journey.studio.peersHere': 'zde',
'journey.studio.folioAuto': 'Automaticky',
'journey.studio.exportLayout': 'Rozvržení',
+1
View File
@@ -952,6 +952,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'V souboru {name} není žádný soubor KML',
'tours.import.archiveTooLargeIn': 'Soubor {name} je po rozbalení příliš velký',
'tours.import.previewDuplicate': 'Už je v cestě',
'tours.import.previewAdopts': 'Už je v cestě jako trasa, stane se výletem',
'tours.import.previewAllDuplicates': 'Všechny trasy už v cestě jsou, není co importovat.',
'tours.import.previewAs': 'Importovat jako',
'tours.import.previewConfirm': 'Importovat {count} túr',
+1 -1
View File
@@ -300,7 +300,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Automatisch anordnen',
'journey.studio.export': 'Exportieren',
'journey.studio.day': 'TAG {day}',
'journey.studio.stations': 'Stationen',
'journey.studio.stations': 'Unterwegs',
'journey.studio.peersHere': 'hier',
'journey.studio.folioAuto': 'Automatisch',
'journey.studio.exportLayout': 'Layout', // same-as-en
+1
View File
@@ -975,6 +975,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Keine KML-Datei in {name} gefunden',
'tours.import.archiveTooLargeIn': '{name} ist entpackt zu groß',
'tours.import.previewDuplicate': 'Schon in der Reise',
'tours.import.previewAdopts': 'Schon als Track in der Reise, wird zur Tour',
'tours.import.previewAllDuplicates': 'Alle Tracks sind schon in der Reise, es gibt nichts zu importieren.',
'tours.import.previewAs': 'Importieren als',
'tours.import.previewConfirm': '{count} Touren importieren',
+1 -1
View File
@@ -297,7 +297,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Auto layout',
'journey.studio.export': 'Export',
'journey.studio.day': 'DAY {day}',
'journey.studio.stations': 'Stations',
'journey.studio.stations': 'Along the way',
'journey.studio.peersHere': 'here',
'journey.studio.folioAuto': 'Automatic',
'journey.studio.exportLayout': 'Layout',
+1
View File
@@ -946,6 +946,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'No KML file found in {name}',
'tours.import.archiveTooLargeIn': '{name} is too large once unpacked',
'tours.import.previewDuplicate': 'Already in the trip',
'tours.import.previewAdopts': 'Already a track in the trip, becomes a tour',
'tours.import.previewAllDuplicates': 'Every track is already in the trip, so there is nothing to import.',
'tours.import.previewAs': 'Import as',
'tours.import.previewConfirm': 'Import {count} tours',
+1 -1
View File
@@ -299,7 +299,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Diseño automático',
'journey.studio.export': 'Exportar',
'journey.studio.day': 'DÍA {day}',
'journey.studio.stations': 'Etapas',
'journey.studio.stations': 'Por el camino',
'journey.studio.peersHere': 'aquí',
'journey.studio.folioAuto': 'Automático',
'journey.studio.exportLayout': 'Diseño',
+1
View File
@@ -963,6 +963,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'No hay ningún archivo KML en {name}',
'tours.import.archiveTooLargeIn': '{name} es demasiado grande una vez descomprimido',
'tours.import.previewDuplicate': 'Ya está en el viaje',
'tours.import.previewAdopts': 'Ya es un track en el viaje, pasa a ser una excursión',
'tours.import.previewAllDuplicates': 'Todos los tracks ya están en el viaje, no hay nada que importar.',
'tours.import.previewAs': 'Importar como',
'tours.import.previewConfirm': 'Importar {count} rutas',
+1 -1
View File
@@ -299,7 +299,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Automaatne paigutus',
'journey.studio.export': 'Eksport',
'journey.studio.day': '{day}. PÄEV',
'journey.studio.stations': 'Peatused',
'journey.studio.stations': 'Teel',
'journey.studio.peersHere': 'siin',
'journey.studio.folioAuto': 'Automaatne',
'journey.studio.exportLayout': 'Paigutus',
+1
View File
@@ -948,6 +948,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Failis {name} pole KML-faili',
'tours.import.archiveTooLargeIn': 'Fail {name} on lahtipakituna liiga suur',
'tours.import.previewDuplicate': 'Juba reisil',
'tours.import.previewAdopts': 'Juba reisil rajana, muutub retkeks',
'tours.import.previewAllDuplicates': 'Kõik rajad on juba reisil, pole midagi importida.',
'tours.import.previewAs': 'Impordi kui',
'tours.import.previewConfirm': 'Impordi {count} matka',
+1 -1
View File
@@ -299,7 +299,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Mise en page auto',
'journey.studio.export': 'Exporter',
'journey.studio.day': 'JOUR {day}',
'journey.studio.stations': 'Étapes',
'journey.studio.stations': 'En chemin',
'journey.studio.peersHere': 'ici',
'journey.studio.folioAuto': 'Automatique',
'journey.studio.exportLayout': 'Mise en page',
+1
View File
@@ -981,6 +981,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Aucun fichier KML trouvé dans {name}',
'tours.import.archiveTooLargeIn': '{name} est trop volumineux une fois décompressé',
'tours.import.previewDuplicate': 'Déjà dans le voyage',
'tours.import.previewAdopts': 'Déjà une trace dans le voyage, devient une sortie',
'tours.import.previewAllDuplicates': 'Toutes les traces sont déjà dans le voyage : il n’y a rien à importer.',
'tours.import.previewAs': 'Importer comme',
'tours.import.previewConfirm': 'Importer {count} sorties',
+1 -1
View File
@@ -302,7 +302,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Αυτόματη διάταξη',
'journey.studio.export': 'Εξαγωγή',
'journey.studio.day': 'ΗΜΕΡΑ {day}',
'journey.studio.stations': 'Στάσεις',
'journey.studio.stations': 'Στη διαδρομή',
'journey.studio.peersHere': 'εδώ',
'journey.studio.folioAuto': 'Αυτόματα',
'journey.studio.exportLayout': 'Διάταξη',
+1
View File
@@ -986,6 +986,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Δεν βρέθηκε αρχείο KML στο {name}',
'tours.import.archiveTooLargeIn': 'Το {name} είναι πολύ μεγάλο μετά την αποσυμπίεση',
'tours.import.previewDuplicate': 'Υπάρχει ήδη στο ταξίδι',
'tours.import.previewAdopts': 'Υπάρχει ήδη ως διαδρομή στο ταξίδι, γίνεται εκδρομή',
'tours.import.previewAllDuplicates': 'Όλα τα ίχνη υπάρχουν ήδη στο ταξίδι, δεν υπάρχει τίποτα για εισαγωγή.',
'tours.import.previewAs': 'Εισαγωγή ως',
'tours.import.previewConfirm': 'Εισαγωγή {count} διαδρομών',
+1 -1
View File
@@ -298,7 +298,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Automatikus elrendezés',
'journey.studio.export': 'Exportálás',
'journey.studio.day': '{day}. NAP',
'journey.studio.stations': 'Állomások',
'journey.studio.stations': 'Útközben',
'journey.studio.peersHere': 'itt',
'journey.studio.folioAuto': 'Automatikus',
'journey.studio.exportLayout': 'Elrendezés',
+1
View File
@@ -963,6 +963,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'A(z) {name} nem tartalmaz KML-fájlt',
'tours.import.archiveTooLargeIn': 'A(z) {name} kicsomagolva túl nagy',
'tours.import.previewDuplicate': 'Már az utazásban van',
'tours.import.previewAdopts': 'Már nyomvonalként szerepel az utazásban, túra lesz belőle',
'tours.import.previewAllDuplicates': 'Minden nyomvonal már az utazásban van, nincs mit importálni.',
'tours.import.previewAs': 'Importálás mint',
'tours.import.previewConfirm': '{count} túra importálása',
+1 -1
View File
@@ -291,7 +291,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Tata letak otomatis',
'journey.studio.export': 'Ekspor',
'journey.studio.day': 'HARI {day}',
'journey.studio.stations': 'Persinggahan',
'journey.studio.stations': 'Sepanjang jalan',
'journey.studio.peersHere': 'di sini',
'journey.studio.folioAuto': 'Otomatis',
'journey.studio.exportLayout': 'Tata letak',
+1
View File
@@ -954,6 +954,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Tidak ada berkas KML di {name}',
'tours.import.archiveTooLargeIn': '{name} terlalu besar setelah diekstrak',
'tours.import.previewDuplicate': 'Sudah ada di perjalanan',
'tours.import.previewAdopts': 'Sudah ada sebagai jalur di perjalanan, menjadi tur',
'tours.import.previewAllDuplicates': 'Semua jejak sudah ada di perjalanan, tidak ada yang perlu diimpor.',
'tours.import.previewAs': 'Impor sebagai',
'tours.import.previewConfirm': 'Impor {count} tur',
+1 -1
View File
@@ -298,7 +298,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Layout automatico',
'journey.studio.export': 'Esporta',
'journey.studio.day': 'GIORNO {day}',
'journey.studio.stations': 'Tappe',
'journey.studio.stations': 'Lungo la strada',
'journey.studio.peersHere': 'qui',
'journey.studio.folioAuto': 'Automatico',
'journey.studio.exportLayout': 'Impaginazione',
+1
View File
@@ -970,6 +970,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Nessun file KML trovato in {name}',
'tours.import.archiveTooLargeIn': '{name} è troppo grande una volta decompresso',
'tours.import.previewDuplicate': 'Già nel viaggio',
'tours.import.previewAdopts': 'Già una traccia nel viaggio, diventa un’escursione',
'tours.import.previewAllDuplicates': 'Tutte le tracce sono già nel viaggio, non c’è nulla da importare.',
'tours.import.previewAs': 'Importa come',
'tours.import.previewConfirm': 'Importa {count} tour',
+1 -1
View File
@@ -290,7 +290,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': '自動レイアウト',
'journey.studio.export': 'エクスポート',
'journey.studio.day': '{day}日目',
'journey.studio.stations': '立ち寄り先',
'journey.studio.stations': '道中で',
'journey.studio.peersHere': '閲覧中',
'journey.studio.folioAuto': '自動',
'journey.studio.exportLayout': 'レイアウト',
+1
View File
@@ -930,6 +930,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': '{name}にKMLファイルが見つかりません',
'tours.import.archiveTooLargeIn': '{name}は展開すると大きすぎます',
'tours.import.previewDuplicate': 'すでに旅行にあります',
'tours.import.previewAdopts': '旅行にトラックとしてあります。ツアーになります',
'tours.import.previewAllDuplicates': 'すべてのトラックがすでに旅行にあるため、取り込むものはありません。',
'tours.import.previewAs': '取り込む種類',
'tours.import.previewConfirm': '{count} 件のツアーを取り込む',
+1 -1
View File
@@ -287,7 +287,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': '자동 레이아웃',
'journey.studio.export': '내보내기',
'journey.studio.day': '{day}일차',
'journey.studio.stations': '경유지',
'journey.studio.stations': '가는 길에',
'journey.studio.peersHere': '접속 중',
'journey.studio.folioAuto': '자동',
'journey.studio.exportLayout': '레이아웃',
+1
View File
@@ -922,6 +922,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': '{name}에 KML 파일이 없습니다',
'tours.import.archiveTooLargeIn': '{name}은(는) 압축을 풀면 너무 큽니다',
'tours.import.previewDuplicate': '이미 여행에 있음',
'tours.import.previewAdopts': '이미 여행에 트랙으로 있음, 투어가 됩니다',
'tours.import.previewAllDuplicates': '모든 트랙이 이미 여행에 있어 가져올 것이 없습니다.',
'tours.import.previewAs': '가져올 유형',
'tours.import.previewConfirm': '투어 {count}개 가져오기',
+1 -1
View File
@@ -300,7 +300,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Automatische opmaak',
'journey.studio.export': 'Exporteren',
'journey.studio.day': 'DAG {day}',
'journey.studio.stations': 'Etappes',
'journey.studio.stations': 'Onderweg',
'journey.studio.peersHere': 'hier',
'journey.studio.folioAuto': 'Automatisch',
'journey.studio.exportLayout': 'Opmaak',
+1
View File
@@ -967,6 +967,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Geen KML-bestand gevonden in {name}',
'tours.import.archiveTooLargeIn': '{name} is uitgepakt te groot',
'tours.import.previewDuplicate': 'Al in de reis',
'tours.import.previewAdopts': 'Al een track in de reis, wordt een tocht',
'tours.import.previewAllDuplicates': 'Alle tracks staan al in de reis, er is niets te importeren.',
'tours.import.previewAs': 'Importeren als',
'tours.import.previewConfirm': '{count} tochten importeren',
+1 -1
View File
@@ -312,7 +312,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Układ automatyczny',
'journey.studio.export': 'Eksportuj',
'journey.studio.day': 'DZIEŃ {day}',
'journey.studio.stations': 'Etapy',
'journey.studio.stations': 'Po drodze',
'journey.studio.peersHere': 'tutaj',
'journey.studio.folioAuto': 'Automatycznie',
'journey.studio.exportLayout': 'Układ',
+1
View File
@@ -986,6 +986,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'W pliku {name} nie ma pliku KML',
'tours.import.archiveTooLargeIn': 'Plik {name} po rozpakowaniu jest za duży',
'tours.import.previewDuplicate': 'Już jest w podróży',
'tours.import.previewAdopts': 'Już jest śladem w podróży, stanie się wycieczką',
'tours.import.previewAllDuplicates': 'Wszystkie ślady są już w podróży, nie ma czego importować.',
'tours.import.previewAs': 'Importuj jako',
'tours.import.previewConfirm': 'Importuj {count} wycieczki',
+1 -1
View File
@@ -312,7 +312,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Автомакет',
'journey.studio.export': 'Экспорт',
'journey.studio.day': 'ДЕНЬ {day}',
'journey.studio.stations': 'Остановки',
'journey.studio.stations': 'По пути',
'journey.studio.peersHere': 'здесь',
'journey.studio.folioAuto': 'Автоматически',
'journey.studio.exportLayout': 'Макет',
+1
View File
@@ -989,6 +989,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'В {name} не найден файл KML',
'tours.import.archiveTooLargeIn': '{name} слишком велик после распаковки',
'tours.import.previewDuplicate': 'Уже в поездке',
'tours.import.previewAdopts': 'Уже есть в поездке как трек, станет туром',
'tours.import.previewAllDuplicates': 'Все треки уже в поездке, импортировать нечего.',
'tours.import.previewAs': 'Импортировать как',
'tours.import.previewConfirm': 'Импортировать {count} тура',
+1 -1
View File
@@ -304,7 +304,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Automatické rozloženie',
'journey.studio.export': 'Exportovať',
'journey.studio.day': 'DEŇ {day}',
'journey.studio.stations': 'Zastávky',
'journey.studio.stations': 'Cestou',
'journey.studio.peersHere': 'tu',
'journey.studio.folioAuto': 'Automaticky',
'journey.studio.exportLayout': 'Rozloženie',
+1
View File
@@ -954,6 +954,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'V súbore {name} nie je žiadny súbor KML',
'tours.import.archiveTooLargeIn': 'Súbor {name} je po rozbalení príliš veľký',
'tours.import.previewDuplicate': 'Už je v ceste',
'tours.import.previewAdopts': 'Už je v ceste ako trasa, stane sa túrou',
'tours.import.previewAllDuplicates': 'Všetky trasy už v ceste sú, nie je čo importovať.',
'tours.import.previewAs': 'Importovať ako',
'tours.import.previewConfirm': 'Importovať {count} túr',
+1 -1
View File
@@ -298,7 +298,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Automatisk layout',
'journey.studio.export': 'Exportera',
'journey.studio.day': 'DAG {day}',
'journey.studio.stations': 'Etapper',
'journey.studio.stations': 'Längs vägen',
'journey.studio.peersHere': 'här',
'journey.studio.folioAuto': 'Automatiskt',
'journey.studio.exportLayout': 'Layout', // same-as-en
+1
View File
@@ -945,6 +945,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Ingen KML-fil hittades i {name}',
'tours.import.archiveTooLargeIn': '{name} är för stor när den packats upp',
'tours.import.previewDuplicate': 'Finns redan i resan',
'tours.import.previewAdopts': 'Finns redan som spår i resan, blir en tur',
'tours.import.previewAllDuplicates': 'Alla spår finns redan i resan, det finns inget att importera.',
'tours.import.previewAs': 'Importera som',
'tours.import.previewConfirm': 'Importera {count} turer',
+1 -1
View File
@@ -287,7 +287,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'เค้าโครงอัตโนมัติ',
'journey.studio.export': 'ส่งออก',
'journey.studio.day': 'วันที่ {day}',
'journey.studio.stations': 'สถานี',
'journey.studio.stations': 'ระหว่างทาง',
'journey.studio.peersHere': 'ที่นี่',
'journey.studio.folioAuto': 'อัตโนมัติ',
'journey.studio.exportLayout': 'เค้าโครง',
+1
View File
@@ -907,6 +907,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'ไม่พบไฟล์ KML ใน {name}',
'tours.import.archiveTooLargeIn': '{name} ใหญ่เกินไปเมื่อแตกไฟล์',
'tours.import.previewDuplicate': 'มีอยู่ในทริปแล้ว',
'tours.import.previewAdopts': 'มีอยู่ในทริปเป็นเส้นทางแล้ว จะกลายเป็นทัวร์',
'tours.import.previewAllDuplicates': 'ทุกแทร็กมีอยู่ในทริปแล้ว จึงไม่มีอะไรให้นำเข้า',
'tours.import.previewAs': 'นำเข้าเป็น',
'tours.import.previewConfirm': 'นำเข้า {count} ทริป',
+1 -1
View File
@@ -20,7 +20,7 @@ const toursModules = import.meta.glob<{ default: TranslationStrings }>('./*/tour
const TOUR_DOMAINS: { file: string; generalKeyCount: number; modules: DomainModules }[] = [
{
file: 'tours',
generalKeyCount: 1349,
generalKeyCount: 1350,
modules: toursModules,
},
{
+1 -1
View File
@@ -297,7 +297,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Otomatik yerleşim',
'journey.studio.export': 'Dışa aktar',
'journey.studio.day': '{day}. GÜN',
'journey.studio.stations': 'Duraklar',
'journey.studio.stations': 'Yol boyunca',
'journey.studio.peersHere': 'burada',
'journey.studio.folioAuto': 'Otomatik',
'journey.studio.exportLayout': 'Yerleşim',
+1
View File
@@ -956,6 +956,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': '{name} içinde KML dosyası bulunamadı',
'tours.import.archiveTooLargeIn': '{name} açıldığında çok büyük',
'tours.import.previewDuplicate': 'Zaten seyahatte',
'tours.import.previewAdopts': 'Seyahatte iz olarak zaten var, tura dönüşecek',
'tours.import.previewAllDuplicates': 'Tüm izler zaten seyahatte, içe aktarılacak bir şey yok.',
'tours.import.previewAs': 'Şu olarak içe aktar',
'tours.import.previewConfirm': '{count} turu içe aktar',
+1 -1
View File
@@ -313,7 +313,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Автомакет',
'journey.studio.export': 'Експорт',
'journey.studio.day': 'ДЕНЬ {day}',
'journey.studio.stations': 'Зупинки',
'journey.studio.stations': 'Дорогою',
'journey.studio.peersHere': 'тут',
'journey.studio.folioAuto': 'Автоматично',
'journey.studio.exportLayout': 'Макет',
+1
View File
@@ -980,6 +980,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'У {name} не знайдено файлу KML',
'tours.import.archiveTooLargeIn': '{name} завеликий після розпакування',
'tours.import.previewDuplicate': 'Уже в подорожі',
'tours.import.previewAdopts': 'Уже є в подорожі як трек, стане туром',
'tours.import.previewAllDuplicates': 'Усі треки вже в подорожі, імпортувати нічого.',
'tours.import.previewAs': 'Імпортувати як',
'tours.import.previewConfirm': 'Імпортувати {count} туру',
+1 -1
View File
@@ -292,7 +292,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Bố cục tự động',
'journey.studio.export': 'Xuất',
'journey.studio.day': 'NGÀY {day}',
'journey.studio.stations': 'Chặng dừng',
'journey.studio.stations': 'Dọc đường',
'journey.studio.peersHere': 'ở đây',
'journey.studio.folioAuto': 'Tự động',
'journey.studio.exportLayout': 'Bố cục',
+1
View File
@@ -941,6 +941,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Không tìm thấy tệp KML trong {name}',
'tours.import.archiveTooLargeIn': '{name} quá lớn sau khi giải nén',
'tours.import.previewDuplicate': 'Đã có trong chuyến đi',
'tours.import.previewAdopts': 'Đã có trong chuyến đi dưới dạng tuyến, sẽ thành chuyến tham quan',
'tours.import.previewAllDuplicates': 'Mọi vết đã có trong chuyến đi, không có gì để nhập.',
'tours.import.previewAs': 'Nhập dưới dạng',
'tours.import.previewConfirm': 'Nhập {count} chuyến',
+1 -1
View File
@@ -285,7 +285,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': '自動排版',
'journey.studio.export': '匯出',
'journey.studio.day': '第{day}天',
'journey.studio.stations': '途經地',
'journey.studio.stations': '沿途',
'journey.studio.peersHere': '在線',
'journey.studio.folioAuto': '自動',
'journey.studio.exportLayout': '版面',
+1
View File
@@ -875,6 +875,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': '{name} 中沒有 KML 檔案',
'tours.import.archiveTooLargeIn': '{name} 解壓縮後過大',
'tours.import.previewDuplicate': '已在旅程中',
'tours.import.previewAdopts': '已作為軌跡在旅程中,將變成遊程',
'tours.import.previewAllDuplicates': '所有軌跡都已在旅程中,沒有可匯入的內容。',
'tours.import.previewAs': '匯入為',
'tours.import.previewConfirm': '匯入 {count} 條路線',
+1 -1
View File
@@ -285,7 +285,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': '自动排版',
'journey.studio.export': '导出',
'journey.studio.day': '第{day}天',
'journey.studio.stations': '途经地',
'journey.studio.stations': '沿途',
'journey.studio.peersHere': '在线',
'journey.studio.folioAuto': '自动',
'journey.studio.exportLayout': '版式',
+1
View File
@@ -876,6 +876,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': '{name} 中没有 KML 文件',
'tours.import.archiveTooLargeIn': '{name} 解压后过大',
'tours.import.previewDuplicate': '已在行程中',
'tours.import.previewAdopts': '已作为轨迹在行程中,将变为游览',
'tours.import.previewAllDuplicates': '所有轨迹都已在行程中,没有可导入的内容。',
'tours.import.previewAs': '导入为',
'tours.import.previewConfirm': '导入 {count} 条路线',
@@ -54,6 +54,24 @@ describe('Tours GPX preview', () => {
};
expect(tourGpxPreviewResponseSchema.parse(preview)).toEqual(preview);
});
it('carries a track taken over from before Tours, and nothing but a boolean for it (#2810)', () => {
const track = {
name: 'Garmin hike',
geometry: [
[46.1, 11.1, 1200],
[46.2, 11.2, 1300],
],
point_count: 2,
distance_km: 13.6,
elevation_gain_m: 100,
elevation_loss_m: 0,
duplicate: false,
adopts_track: true,
};
expect(tourGpxPreviewResponseSchema.parse({ tracks: [track] })).toEqual({ tracks: [track] });
expect(tourGpxPreviewResponseSchema.safeParse({ tracks: [{ ...track, adopts_track: 'yes' }] }).success).toBe(false);
});
});
describe('Tours KML preview (IO-02)', () => {
@@ -40,6 +40,12 @@ export const tourGpxPreviewTrackSchema = z.object({
ended_at: z.string().nullable().optional(),
/** Already in the trip under the same name and start: the import skips it, as it always has. */
duplicate: z.boolean(),
/**
* A track of the same name already in the trip that is not a tour yet (one
* imported before Tours, #2810): the import makes that place the tour
* instead of skipping it. Never set together with `duplicate`.
*/
adopts_track: z.boolean().optional(),
});
export type TourGpxPreviewTrack = z.infer<typeof tourGpxPreviewTrackSchema>;