mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
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:
@@ -251,7 +251,7 @@
|
|||||||
"mobile/screens/trip/sheets/MMehrSheet.tsx": 3,
|
"mobile/screens/trip/sheets/MMehrSheet.tsx": 3,
|
||||||
"mobile/screens/trip/sheets/MNoteSheet.tsx": 1,
|
"mobile/screens/trip/sheets/MNoteSheet.tsx": 1,
|
||||||
"mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2,
|
"mobile/screens/trip/sheets/MPlaceEditSheet.tsx": 2,
|
||||||
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 27,
|
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 23,
|
||||||
"mobile/screens/trip/sheets/MReservationSheet.tsx": 2,
|
"mobile/screens/trip/sheets/MReservationSheet.tsx": 2,
|
||||||
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
|
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
|
||||||
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
|
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import type { PlacePhotoCandidate } from '@trek/shared';
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { buildTripFile } from '../../../tests/helpers/factories';
|
import { buildTripFile } from '../../../tests/helpers/factories';
|
||||||
import { fetchImageAsBlob } from '../../api/authUrl';
|
import { placePictureRepo } from '../../repo/placePictureRepo';
|
||||||
import { normalizeImageFile } from '../../utils/convertHeic';
|
import { normalizeImageFile } from '../../utils/convertHeic';
|
||||||
import PlaceImagePicker, { PhotoCredit, type PlaceImageOwn } from './PlaceImagePicker';
|
import PlaceImagePicker, { PhotoCredit, type PlaceImageOwn } from './PlaceImagePicker';
|
||||||
import type { ChosenImage } from './placeImageChoice';
|
import type { ChosenImage } from './placeImageChoice';
|
||||||
|
|
||||||
vi.mock('../../api/authUrl', () => ({ fetchImageAsBlob: vi.fn() }));
|
vi.mock('../../repo/placePictureRepo', () => ({ placePictureRepo: { attachedImage: vi.fn() } }));
|
||||||
vi.mock('../../utils/convertHeic', () => ({
|
vi.mock('../../utils/convertHeic', () => ({
|
||||||
normalizeImageFile: vi.fn(async (file: File) => new File([file], `normalized-${file.name}`, { type: 'image/jpeg' })),
|
normalizeImageFile: vi.fn(async (file: File) => new File([file], `normalized-${file.name}`, { type: 'image/jpeg' })),
|
||||||
}));
|
}));
|
||||||
@@ -81,8 +81,8 @@ function renderPicker(
|
|||||||
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
|
const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(fetchImageAsBlob).mockReset();
|
vi.mocked(placePictureRepo.attachedImage).mockReset();
|
||||||
vi.mocked(fetchImageAsBlob).mockResolvedValue('blob:attached');
|
vi.mocked(placePictureRepo.attachedImage).mockResolvedValue('blob:attached');
|
||||||
vi.mocked(normalizeImageFile).mockClear();
|
vi.mocked(normalizeImageFile).mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,19 +168,19 @@ describe('PlaceImagePicker', () => {
|
|||||||
renderPicker({ own: mine });
|
renderPicker({ own: mine });
|
||||||
const tile = screen.getByRole('button', { name: 'facade.png' });
|
const tile = screen.getByRole('button', { name: 'facade.png' });
|
||||||
await waitFor(() => expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:attached'));
|
await waitFor(() => expect(tile.querySelector('img')).toHaveAttribute('src', 'blob:attached'));
|
||||||
expect(fetchImageAsBlob).toHaveBeenCalledWith('/api/files/41');
|
expect(placePictureRepo.attachedImage).toHaveBeenCalledWith('/api/files/41');
|
||||||
|
|
||||||
fireEvent.click(tile);
|
fireEvent.click(tile);
|
||||||
expect(mine.onPickAttached).toHaveBeenCalledWith(ATTACHED);
|
expect(mine.onPickAttached).toHaveBeenCalledWith(ATTACHED);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-PLIMGPICK-009: a chosen attached image is ticked and named under the grid; one that fails to load stays blank', async () => {
|
it('FE-PLANNER-PLIMGPICK-009: a chosen attached image is ticked and named under the grid; one that fails to load stays blank', async () => {
|
||||||
vi.mocked(fetchImageAsBlob).mockRejectedValue(new Error('gone'));
|
vi.mocked(placePictureRepo.attachedImage).mockRejectedValue(new Error('gone'));
|
||||||
renderPicker({ own: own({ attached: [ATTACHED] }), chosen: { kind: 'attached', fileId: 41 } });
|
renderPicker({ own: own({ attached: [ATTACHED] }), chosen: { kind: 'attached', fileId: 41 } });
|
||||||
const tile = screen.getByRole('button', { name: 'facade.png' });
|
const tile = screen.getByRole('button', { name: 'facade.png' });
|
||||||
expect(tile).toHaveAttribute('aria-pressed', 'true');
|
expect(tile).toHaveAttribute('aria-pressed', 'true');
|
||||||
expect(screen.getByText('facade.png')).toBeInTheDocument();
|
expect(screen.getByText('facade.png')).toBeInTheDocument();
|
||||||
await waitFor(() => expect(fetchImageAsBlob).toHaveBeenCalled());
|
await waitFor(() => expect(placePictureRepo.attachedImage).toHaveBeenCalled());
|
||||||
expect(tile.querySelector('img')).toBeNull();
|
expect(tile.querySelector('img')).toBeNull();
|
||||||
|
|
||||||
// Focusing it names it as well, and the name stays once it is left.
|
// Focusing it names it as well, and the name stays once it is left.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PlacePhotoCandidate } from '@trek/shared';
|
import type { PlacePhotoCandidate } from '@trek/shared';
|
||||||
import { Check, ImageOff, ImagePlus, Loader2 } from 'lucide-react';
|
import { Check, ImageOff, ImagePlus, Loader2 } from 'lucide-react';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { fetchImageAsBlob } from '../../api/authUrl';
|
import { placePictureRepo } from '../../repo/placePictureRepo';
|
||||||
import type { TranslationFn, TripFile } from '../../types';
|
import type { TranslationFn, TripFile } from '../../types';
|
||||||
import { normalizeImageFile } from '../../utils/convertHeic';
|
import { normalizeImageFile } from '../../utils/convertHeic';
|
||||||
import { Tooltip } from '../shared/Tooltip';
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
@@ -308,7 +308,8 @@ function AttachedTile({
|
|||||||
const [src, setSrc] = useState<string | null>(null);
|
const [src, setSrc] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let current = true;
|
let current = true;
|
||||||
fetchImageAsBlob(file.url)
|
placePictureRepo
|
||||||
|
.attachedImage(file.url)
|
||||||
.then((url) => {
|
.then((url) => {
|
||||||
if (current) setSrc(url);
|
if (current) setSrc(url);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ import { __clearEnrichmentCacheForTests, readCachedEnrichment, storeEnrichment }
|
|||||||
import { usePlaceGallery } from './usePlaceGallery';
|
import { usePlaceGallery } from './usePlaceGallery';
|
||||||
|
|
||||||
const placeEnrichment = vi.fn();
|
const placeEnrichment = vi.fn();
|
||||||
// Partial: the store registry behind resetAllStores needs the real default client.
|
vi.mock('../../repo/placePictureRepo', () => ({
|
||||||
vi.mock('../../api/client', async (importOriginal) => ({
|
placePictureRepo: { enrichment: (...a: unknown[]) => placeEnrichment(...a) },
|
||||||
...(await importOriginal<typeof import('../../api/client')>()),
|
|
||||||
mapsApi: { placeEnrichment: (...a: unknown[]) => placeEnrichment(...a) },
|
|
||||||
}));
|
}));
|
||||||
// One toast for every render, the way the real hook memoises it.
|
// One toast for every render, the way the real hook memoises it.
|
||||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PlacePhotoCandidate } from '@trek/shared';
|
import type { PlacePhotoCandidate } from '@trek/shared';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { mapsApi } from '../../api/client';
|
|
||||||
import { translateApiError, useTranslation } from '../../i18n';
|
import { translateApiError, useTranslation } from '../../i18n';
|
||||||
|
import { placePictureRepo } from '../../repo/placePictureRepo';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import type { Place } from '../../types';
|
import type { Place } from '../../types';
|
||||||
import { useToast } from '../shared/Toast';
|
import { useToast } from '../shared/Toast';
|
||||||
@@ -117,8 +117,8 @@ export function usePlaceGallery(place: Place, language: string): UsePlaceGallery
|
|||||||
// An answer for a place the user has left is kept for that place, but opens nothing and says nothing.
|
// An answer for a place the user has left is kept for that place, but opens nothing and says nothing.
|
||||||
const current = () => shownRef.current === placeId;
|
const current = () => shownRef.current === placeId;
|
||||||
setLoadingFor(placeId);
|
setLoadingFor(placeId);
|
||||||
mapsApi
|
placePictureRepo
|
||||||
.placeEnrichment({ ...selection, lang: language })
|
.enrichment({ ...selection, lang: language })
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
storeEnrichment(selection, language, result);
|
storeEnrichment(selection, language, result);
|
||||||
if (current()) apply(result.photos);
|
if (current()) apply(result.photos);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// FE-PLACE-PICTURE-REPO-001 to FE-PLACE-PICTURE-REPO-002: the pictures a place can be given,
|
||||||
|
// asked of the maps route and the attached files behind the repository.
|
||||||
|
import type { MapsPlaceEnrichmentResult } from '@trek/shared';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fetchImageAsBlob } from '../api/authUrl';
|
||||||
|
import { mapsApi } from '../api/client';
|
||||||
|
import { placePictureRepo } from './placePictureRepo';
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({ mapsApi: { placeEnrichment: vi.fn() } }));
|
||||||
|
vi.mock('../api/authUrl', () => ({ fetchImageAsBlob: vi.fn() }));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('place pictures', () => {
|
||||||
|
it('FE-PLACE-PICTURE-REPO-001: asks the maps route for the enrichment with the query and the signal', async () => {
|
||||||
|
const answer = { photos: [] } as unknown as MapsPlaceEnrichmentResult;
|
||||||
|
vi.mocked(mapsApi.placeEnrichment).mockResolvedValue(answer);
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
const query = { placeId: 'way:2', lat: 48.1, lng: 11.5, name: 'Rathaus', lang: 'de' };
|
||||||
|
|
||||||
|
await expect(placePictureRepo.enrichment(query, signal)).resolves.toBe(answer);
|
||||||
|
expect(mapsApi.placeEnrichment).toHaveBeenCalledWith(query, signal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLACE-PICTURE-REPO-002: reads an attached image as an object URL', async () => {
|
||||||
|
vi.mocked(fetchImageAsBlob).mockResolvedValue('blob:attached');
|
||||||
|
|
||||||
|
await expect(placePictureRepo.attachedImage('/api/files/41')).resolves.toBe('blob:attached');
|
||||||
|
expect(fetchImageAsBlob).toHaveBeenCalledWith('/api/files/41');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { MapsPlaceEnrichmentResult } from '@trek/shared';
|
||||||
|
import { fetchImageAsBlob } from '../api/authUrl';
|
||||||
|
import { mapsApi } from '../api/client';
|
||||||
|
|
||||||
|
/** What the sources are asked for one place: where it is, what it is called, in which language. */
|
||||||
|
interface PlaceEnrichmentQuery {
|
||||||
|
placeId?: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
name: string;
|
||||||
|
lang?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pictures a place can be given: the sources' photos and the images
|
||||||
|
* attached to it. Online only by nature, and nothing here is cached on the
|
||||||
|
* device; the session cache for enrichment lives with the planner view.
|
||||||
|
*/
|
||||||
|
export const placePictureRepo = {
|
||||||
|
/** The sources' pictures and description for a place, as the maps route answers them. */
|
||||||
|
enrichment(query: PlaceEnrichmentQuery, signal?: AbortSignal): Promise<MapsPlaceEnrichmentResult> {
|
||||||
|
return mapsApi.placeEnrichment(query, signal);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** An attached image as an object URL the view can show; empty when it could not be read. */
|
||||||
|
attachedImage(url: string): Promise<string> {
|
||||||
|
return fetchImageAsBlob(url);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -82,7 +82,6 @@
|
|||||||
"server/src/nest/backup/backup-archive.ts": 1,
|
"server/src/nest/backup/backup-archive.ts": 1,
|
||||||
"server/src/nest/backup/backup.impl.ts": 3,
|
"server/src/nest/backup/backup.impl.ts": 3,
|
||||||
"server/src/nest/budget/budget.service.ts": 1,
|
"server/src/nest/budget/budget.service.ts": 1,
|
||||||
"server/src/nest/common/validate-managed-routes.ts": 1,
|
|
||||||
"server/src/nest/day-notes/day-notes.service.ts": 1,
|
"server/src/nest/day-notes/day-notes.service.ts": 1,
|
||||||
"server/src/nest/doc-sync/providers/paperless.client.ts": 7,
|
"server/src/nest/doc-sync/providers/paperless.client.ts": 7,
|
||||||
"server/src/nest/doc-sync/providers/paperless.provider.ts": 1,
|
"server/src/nest/doc-sync/providers/paperless.provider.ts": 1,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { PlaceImportService } from '../place-import/place-import.service';
|
|||||||
import type { PreparedGpxPlace } from '../place-import/place-import.types';
|
import type { PreparedGpxPlace } from '../place-import/place-import.types';
|
||||||
import { PlacesService } from '../places/places.service';
|
import { PlacesService } from '../places/places.service';
|
||||||
import { readTourFile } from './tour-file-import.helpers';
|
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 { computeTourMetrics, parseRouteGeometry } from './tours.helpers';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { TOUR_GPX_TEXT_MAX, type TourFileLeftOut } from '@trek/shared';
|
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);
|
const { rows, leftOut } = await readTourFile(this.placeImport, buffer, fileName);
|
||||||
if (rows.length === 0) return { tracks: [], whole: false };
|
if (rows.length === 0) return { tracks: [], whole: false };
|
||||||
const duplicates = await this.places.gpxDuplicates(String(tripId), rows);
|
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 } : {};
|
const extra = leftOut ? { left_out: leftOut } : {};
|
||||||
if (isGpxName(fileName) && !texts) {
|
if (!isGpxName(fileName)) {
|
||||||
return { tracks: [this.wholeFile(buffer.toString('utf8'), rows, fileName)], whole: true, ...extra };
|
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 text = buffer.toString('utf8');
|
||||||
const own = texts?.[index] ?? gpxOfLine(row.name, row.description, parseRouteGeometry(row.routeGeometry));
|
const pieces = this.cutGpx(text, rows, fileName);
|
||||||
return this.trackOf(row, own, duplicates[index] ?? false);
|
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 };
|
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 {
|
* A GPX text cut into its tracks, in the file's order, each paired with the
|
||||||
const pieces = splitGpxTracks(text);
|
* row the reader made of it. The reader gives every route before every
|
||||||
if (pieces.length !== count) return null;
|
* track, so the k-th `<rte>` is row k and the k-th `<trk>` the row after all
|
||||||
const readable = pieces.every((piece) => {
|
* routes. Null where the cut does not find one piece per row, or a piece
|
||||||
const rows = this.placeImport.readGpx(Buffer.from(piece, 'utf8'), { ...GPX_OPTIONS, defaultName: fileName });
|
* does not read back as exactly the line of its row.
|
||||||
return rows.length === 1;
|
*/
|
||||||
});
|
private cutGpx(
|
||||||
return readable ? pieces : null;
|
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 {
|
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.
|
* 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. */
|
/** Where every `<trk>` and `<rte>` element of a GPX text starts and ends, in order. */
|
||||||
function trackBlocks(gpx: string): Array<{ from: number; to: number }> {
|
function trackBlocks(gpx: string): Array<{ tag: TrackTag; from: number; to: number }> {
|
||||||
const blocks: Array<{ from: number; to: number }> = [];
|
const blocks: Array<{ tag: TrackTag; from: number; to: number }> = [];
|
||||||
let at = 0;
|
let at = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const trk = openingAt(gpx, 'trk', at);
|
const trk = openingAt(gpx, 'trk', at);
|
||||||
const rte = openingAt(gpx, 'rte', at);
|
const rte = openingAt(gpx, 'rte', at);
|
||||||
const next = [trk, rte].filter((found) => found >= 0).sort((a, b) => a - b)[0];
|
const next = [trk, rte].filter((found) => found >= 0).sort((a, b) => a - b)[0];
|
||||||
if (next === undefined) return blocks;
|
if (next === undefined) return blocks;
|
||||||
const tag = next === trk ? 'trk' : 'rte';
|
const tag: TrackTag = next === trk ? 'trk' : 'rte';
|
||||||
const close = gpx.indexOf(`</${tag}>`, next);
|
const close = gpx.indexOf(`</${tag}>`, next);
|
||||||
if (close < 0) return blocks;
|
if (close < 0) return blocks;
|
||||||
const to = close + tag.length + 3;
|
const to = close + tag.length + 3;
|
||||||
blocks.push({ from: next, to });
|
blocks.push({ tag, from: next, to });
|
||||||
at = 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
|
* One GPX text per track and route of a GPX file, in the file's order: the
|
||||||
* declaration, the `<gpx>` element and its metadata) around each element.
|
* file's head (its declaration, the `<gpx>` element and its metadata) around
|
||||||
* Empty where the file holds none the cut can find (a namespace prefix, say).
|
* 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 blocks = trackBlocks(gpx);
|
||||||
const first = blocks[0];
|
const first = blocks[0];
|
||||||
if (!first) return [];
|
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.
|
// The head ends before the first waypoint as well: an import never takes loose points.
|
||||||
const wpt = openingAt(head, 'wpt', 0);
|
const wpt = openingAt(head, 'wpt', 0);
|
||||||
const kept = wpt >= 0 ? head.slice(0, wpt) : head;
|
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. */
|
/** 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 { TourChangeSteps } from '../../../src/db/entities/TourChangeSteps.entity';
|
||||||
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
|
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
|
||||||
import { Tours } from '../../../src/db/entities/Tours.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 { PaddleRoutingService } from '../../../src/nest/paddle-routing/paddle-routing.service';
|
||||||
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
|
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
|
||||||
import { SkiRoutingService } from '../../../src/nest/ski-routing/ski-routing.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. */
|
/** 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> {
|
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);
|
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 { withRequestContext } from '../../../../src/nest/database/request-context';
|
||||||
import { makePlace } from '../../../helpers/factories/places';
|
import { makePlace } from '../../../helpers/factories/places';
|
||||||
import { findRows } from '../../../helpers/factories/rows';
|
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 { makeTrip } from '../../../helpers/factories/trips';
|
||||||
import { makeUser } from '../../../helpers/factories/users';
|
import { makeUser } from '../../../helpers/factories/users';
|
||||||
import { FakeRealtimeService } from '../../../helpers/fake-realtime';
|
import { FakeRealtimeService } from '../../../helpers/fake-realtime';
|
||||||
@@ -77,6 +77,8 @@ beforeAll(async () => {
|
|||||||
tools = app.get(AssistantToolsService);
|
tools = app.get(AssistantToolsService);
|
||||||
const addons = ['packing', 'budget', 'collections', 'journey', 'roadtrip'];
|
const addons = ['packing', 'budget', 'collections', 'journey', 'roadtrip'];
|
||||||
await Promise.all(addons.map((addon) => setAddonEnabled(orm, addon, true)));
|
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);
|
const { user } = await makeUser(orm);
|
||||||
listed = await listRaw(user.id);
|
listed = await listRaw(user.id);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user