fix: the last red checks after the tour, home and place picture merges

A GPX file that mixes routes and tracks gives each its own text: the pieces
are matched to the reader's rows by kind, routes first as the reader gives
them, where they were paired by index and a route got a track's points. The
place pictures reach the network through a small repo instead of the api
module, the wanderer parity case and the tool catalog test allow wanderer,
and the knip and theme baselines follow the lower counts.
This commit is contained in:
Maurice
2026-10-23 22:40:14 +02:00
parent 770fb2a2b3
commit 31dba65906
12 changed files with 151 additions and 46 deletions
+41 -17
View File
@@ -2,7 +2,7 @@ import { PlaceImportService } from '../place-import/place-import.service';
import type { PreparedGpxPlace } from '../place-import/place-import.types';
import { PlacesService } from '../places/places.service';
import { readTourFile } from './tour-file-import.helpers';
import { gpxOfLine, splitGpxTracks } from './tour-file-split.helpers';
import { gpxOfLine, splitGpxTrackPieces } from './tour-file-split.helpers';
import { computeTourMetrics, parseRouteGeometry } from './tours.helpers';
import { Injectable } from '@nestjs/common';
import { TOUR_GPX_TEXT_MAX, type TourFileLeftOut } from '@trek/shared';
@@ -56,27 +56,51 @@ export class TourFileReadService {
const { rows, leftOut } = await readTourFile(this.placeImport, buffer, fileName);
if (rows.length === 0) return { tracks: [], whole: false };
const duplicates = await this.places.gpxDuplicates(String(tripId), rows);
const texts = isGpxName(fileName) ? this.cutGpx(buffer.toString('utf8'), rows.length, fileName) : null;
const extra = leftOut ? { left_out: leftOut } : {};
if (isGpxName(fileName) && !texts) {
return { tracks: [this.wholeFile(buffer.toString('utf8'), rows, fileName)], whole: true, ...extra };
if (!isGpxName(fileName)) {
const tracks = rows.map((row, index) =>
this.trackOf(
row,
gpxOfLine(row.name, row.description, parseRouteGeometry(row.routeGeometry)),
duplicates[index] ?? false,
),
);
return { tracks, whole: false, ...extra };
}
const tracks = rows.map((row, index) => {
const own = texts?.[index] ?? gpxOfLine(row.name, row.description, parseRouteGeometry(row.routeGeometry));
return this.trackOf(row, own, duplicates[index] ?? false);
});
const text = buffer.toString('utf8');
const pieces = this.cutGpx(text, rows, fileName);
if (!pieces) return { tracks: [this.wholeFile(text, rows, fileName)], whole: true, ...extra };
const tracks = pieces.map(({ row, gpx, index }) => this.trackOf(row, gpx, duplicates[index] ?? false));
return { tracks, whole: false, ...extra };
}
/** A GPX text cut into its tracks; null where a piece does not read back as exactly the one it should. */
private cutGpx(text: string, count: number, fileName: string): string[] | null {
const pieces = splitGpxTracks(text);
if (pieces.length !== count) return null;
const readable = pieces.every((piece) => {
const rows = this.placeImport.readGpx(Buffer.from(piece, 'utf8'), { ...GPX_OPTIONS, defaultName: fileName });
return rows.length === 1;
});
return readable ? pieces : null;
/**
* A GPX text cut into its tracks, in the file's order, each paired with the
* row the reader made of it. The reader gives every route before every
* track, so the k-th `<rte>` is row k and the k-th `<trk>` the row after all
* routes. Null where the cut does not find one piece per row, or a piece
* does not read back as exactly the line of its row.
*/
private cutGpx(
text: string,
rows: readonly PreparedGpxPlace[],
fileName: string,
): Array<{ row: PreparedGpxPlace; gpx: string; index: number }> | null {
const pieces = splitGpxTrackPieces(text);
if (pieces.length !== rows.length) return null;
const routeCount = pieces.filter((piece) => piece.tag === 'rte').length;
let routes = 0;
let tracks = 0;
const paired: Array<{ row: PreparedGpxPlace; gpx: string; index: number }> = [];
for (const piece of pieces) {
const index = piece.tag === 'rte' ? routes++ : routeCount + tracks++;
const row = rows[index];
if (!row) return null;
const back = this.placeImport.readGpx(Buffer.from(piece.gpx, 'utf8'), { ...GPX_OPTIONS, defaultName: fileName });
if (back.length !== 1 || back[0]?.routeGeometry !== row.routeGeometry) return null;
paired.push({ row, gpx: piece.gpx, index });
}
return paired;
}
private trackOf(row: PreparedGpxPlace, gpx: string, duplicate: boolean): TourFileTrack {
@@ -8,20 +8,22 @@ import { escapeHtml, type GeometryPoint } from '@trek/shared';
* holds no times, is written as GPX from its points.
*/
type TrackTag = 'trk' | 'rte';
/** Where every `<trk>` and `<rte>` element of a GPX text starts and ends, in order. */
function trackBlocks(gpx: string): Array<{ from: number; to: number }> {
const blocks: Array<{ from: number; to: number }> = [];
function trackBlocks(gpx: string): Array<{ tag: TrackTag; from: number; to: number }> {
const blocks: Array<{ tag: TrackTag; from: number; to: number }> = [];
let at = 0;
for (;;) {
const trk = openingAt(gpx, 'trk', at);
const rte = openingAt(gpx, 'rte', at);
const next = [trk, rte].filter((found) => found >= 0).sort((a, b) => a - b)[0];
if (next === undefined) return blocks;
const tag = next === trk ? 'trk' : 'rte';
const tag: TrackTag = next === trk ? 'trk' : 'rte';
const close = gpx.indexOf(`</${tag}>`, next);
if (close < 0) return blocks;
const to = close + tag.length + 3;
blocks.push({ from: next, to });
blocks.push({ tag, from: next, to });
at = to;
}
}
@@ -38,12 +40,19 @@ function openingAt(text: string, tag: string, at: number): number {
}
}
/** One piece cut out of a GPX file: whether it is a track or a route, and its own GPX text. */
export interface GpxTrackPiece {
tag: TrackTag;
gpx: string;
}
/**
* One GPX text per track and route of a GPX file: the file's head (its
* declaration, the `<gpx>` element and its metadata) around each element.
* Empty where the file holds none the cut can find (a namespace prefix, say).
* One GPX text per track and route of a GPX file, in the file's order: the
* file's head (its declaration, the `<gpx>` element and its metadata) around
* each element. Empty where the file holds none the cut can find (a
* namespace prefix, say).
*/
export function splitGpxTracks(gpx: string): string[] {
export function splitGpxTrackPieces(gpx: string): GpxTrackPiece[] {
const blocks = trackBlocks(gpx);
const first = blocks[0];
if (!first) return [];
@@ -51,7 +60,12 @@ export function splitGpxTracks(gpx: string): string[] {
// The head ends before the first waypoint as well: an import never takes loose points.
const wpt = openingAt(head, 'wpt', 0);
const kept = wpt >= 0 ? head.slice(0, wpt) : head;
return blocks.map(({ from, to }) => `${kept}${gpx.slice(from, to)}\n</gpx>\n`);
return blocks.map(({ tag, from, to }) => ({ tag, gpx: `${kept}${gpx.slice(from, to)}\n</gpx>\n` }));
}
/** The GPX texts of splitGpxTrackPieces, in the file's order. */
export function splitGpxTracks(gpx: string): string[] {
return splitGpxTrackPieces(gpx).map((piece) => piece.gpx);
}
/** A line read from a KML or KMZ file as a GPX text of one track. */
@@ -3,6 +3,7 @@ import { Places } from '../../../src/db/entities/Places.entity';
import { TourChangeSteps } from '../../../src/db/entities/TourChangeSteps.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
import { withRequestContext } from '../../../src/nest/database/request-context';
import { PaddleRoutingService } from '../../../src/nest/paddle-routing/paddle-routing.service';
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
import { SkiRoutingService } from '../../../src/nest/ski-routing/ski-routing.service';
@@ -297,7 +298,10 @@ function wandererTrail(app: INestApplication): () => void {
/** The trail previewed first, as the traveller sees it before the import: its fingerprint is what the import takes. */
async function previewTrail(s: Scene, app: INestApplication): Promise<void> {
const preview = await app.get(ToursWandererService).preview(String(s.trip.id), s.owner.id, 'trail-1');
// Called straight on the service, outside a request: it reads the snapshots through repositories, which need a context.
const preview = await withRequestContext(ormOf(app), () =>
app.get(ToursWandererService).preview(String(s.trip.id), s.owner.id, 'trail-1'),
);
trailHashFor.set(s.n, preview.contentHash);
}
@@ -24,7 +24,7 @@ import { ASSISTANT_ARG_POLICY, ASSISTANT_WRITE_TOOLS } from '../../../../src/nes
import { withRequestContext } from '../../../../src/nest/database/request-context';
import { makePlace } from '../../../helpers/factories/places';
import { findRows } from '../../../helpers/factories/rows';
import { setAddonEnabled } from '../../../helpers/factories/settings';
import { setAddonEnabled, setAppSetting } from '../../../helpers/factories/settings';
import { makeTrip } from '../../../helpers/factories/trips';
import { makeUser } from '../../../helpers/factories/users';
import { FakeRealtimeService } from '../../../helpers/fake-realtime';
@@ -77,6 +77,8 @@ beforeAll(async () => {
tools = app.get(AssistantToolsService);
const addons = ['packing', 'budget', 'collections', 'journey', 'roadtrip'];
await Promise.all(addons.map((addon) => setAddonEnabled(orm, addon, true)));
// The wanderer tools show only while the admin allows wanderer under Tours.
await setAppSetting(orm, 'tours_wanderer_enabled', 'true');
const { user } = await makeUser(orm);
listed = await listRaw(user.id);
});