mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-27 09:08:30 +00:00
Compare commits
12 Commits
fix/ai-own-server
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 35fb59e64e | |||
| ee3fff1dab | |||
| 3bc75d2b1c | |||
| 094426debb | |||
| 313198ba63 | |||
| e677d58f91 | |||
| bc60aea839 | |||
| b5cd951bd2 | |||
| fd818050bd | |||
| 0313597b10 | |||
| 2c867673bf | |||
| 39ebf0976c |
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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;`);
|
||||
|
||||
@@ -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': 'التخطيط',
|
||||
|
||||
@@ -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} جولة',
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ó',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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í',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Διάταξη',
|
||||
|
||||
@@ -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} διαδρομών',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'レイアウト',
|
||||
|
||||
@@ -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} 件のツアーを取り込む',
|
||||
|
||||
@@ -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': '레이아웃',
|
||||
|
||||
@@ -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}개 가져오기',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Макет',
|
||||
|
||||
@@ -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} тура',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'เค้าโครง',
|
||||
|
||||
@@ -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} ทริป',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Макет',
|
||||
|
||||
@@ -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} туру',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '版面',
|
||||
|
||||
@@ -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} 條路線',
|
||||
|
||||
@@ -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': '版式',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user