Compare commits

...

14 Commits

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

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

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

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

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

The tour import now takes over a track of the trip that has the same name
and is no tour yet: the place keeps its name and line and gets the tours
row, with the recorded times the file adds. The preview says so instead of
"Already in the trip". New and taken-over tracks commit in one transaction.
2026-10-26 12:17:15 +01:00
Maurice 1ecc449f3e test(ai): fix the model-not-found pattern, guard the fetch mocks, and read the booking in an ORM context 2026-10-26 10:20:47 +01:00
Maurice b8990782cc fix(ai): connect and read bookings through any OpenAI-compatible own server
Connecting an own server asked Ollama's /api/tags and gave up on any answer
that was not a 404, and every failure, a refused key included, read as "not
responding". The model list is now looked for at /api/tags, at the address as
entered and at /v1/models, an answer only counts when it is a list, and a
refused key or a server that answered without a list say so (#2808).

AI parsing through an own server always called Ollama's /api/chat, which an
OpenAI-compatible server such as Groq answers with 404. A server without it
now gets the same extraction over /chat/completions at the address as
entered (#2806).
2026-10-26 10:20:46 +01:00
121 changed files with 1053 additions and 183 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 382,
"@typescript-eslint/no-misused-promises": 849,
"@typescript-eslint/no-misused-promises": 848,
"no-console": 88,
"complexity": 220,
"@typescript-eslint/no-explicit-any": 146,
@@ -485,6 +485,17 @@ describe('KategorieGruppe — inline add item', () => {
expect(onAddItem).not.toHaveBeenCalled()
})
it('FE-W5CAT-028a: Enter on the first item typed into the placeholder row opens the add field (#2825)', async () => {
server.use(http.put('/api/trips/1/packing/1', () => HttpResponse.json({ item: buildPackingItem({ id: 1, name: 'Tent', category: 'Gear' }) })))
setup({ items: [buildPackingItem({ id: 1, name: '...', category: 'Gear' })] })
fireEvent.click(screen.getByText('...'))
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
fireEvent.keyDown(screen.getByPlaceholderText('...'), { key: 'Enter' })
await waitFor(() => expect(screen.getByPlaceholderText('Item name...')).toHaveFocus())
})
it('FE-W5CAT-028: the confirm button adds the item, and stays inert while empty', () => {
const onAddItem = vi.fn(async () => {})
const { container } = setup({ onAddItem })
@@ -86,6 +86,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
const [showAddItem, setShowAddItem] = useState(false)
const [newItemName, setNewItemName] = useState('')
const addItemRef = useRef<HTMLInputElement>(null)
const openAddItem = () => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }
const menuBtnRef = useRef<HTMLButtonElement>(null)
const assigneeDropdownRef = useRef<HTMLDivElement>(null)
const { togglePackingItem } = useTripStore()
@@ -292,7 +293,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
const contributions = contribFor(item.id)
return (
<React.Fragment key={item.id}>
<ArtikelZeile item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} divider={index > 0}
<ArtikelZeile item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} onPlaceholderFilled={openAddItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} divider={index > 0}
tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave}
drag={canEdit && reorderable ? {
isDragging: dragId === item.id,
@@ -343,7 +344,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
// A row of its own on a faint tint, so it closes the card instead
// of trailing off: a dashed box in the checkbox column (past the drag
// grip's width) and the label where an item's name sits.
<button type="button" onClick={() => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }}
<button type="button" onClick={openAddItem}
style={{ display: 'flex', alignItems: 'center', gap: 9, width: 'calc(100% - 8px)', margin: '6px 4px 0', paddingBlock: 8, paddingInlineEnd: 10, paddingInlineStart: 28, borderRadius: 10, border: 'none', background: 'var(--bg-secondary)', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'start' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
@@ -257,6 +257,23 @@ describe('ArtikelZeile — renaming', () => {
await waitFor(() => expect(screen.getByText('...')).toBeInTheDocument())
})
it('FE-W5ROW-008b: Enter on a filled placeholder row hands the typing on, a blur does not (#2825)', async () => {
server.use(http.put('/api/trips/1/packing/1', () => HttpResponse.json({ item: buildPackingItem({ id: 1, name: 'Tent' }) })))
const onPlaceholderFilled = vi.fn()
setup({ item: buildPackingItem({ id: 1, name: '...', category: 'Gear' }), onPlaceholderFilled })
fireEvent.click(screen.getByText('...'))
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
fireEvent.blur(screen.getByPlaceholderText('...'))
await waitFor(() => expect(screen.queryByPlaceholderText('...')).toBeNull())
expect(onPlaceholderFilled).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('...'))
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
fireEvent.keyDown(screen.getByPlaceholderText('...'), { key: 'Enter' })
await waitFor(() => expect(onPlaceholderFilled).toHaveBeenCalledTimes(1))
})
it('FE-W5ROW-009: a failing rename surfaces a save error', async () => {
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
setup()
@@ -25,6 +25,8 @@ interface ArtikelZeileProps {
categories: string[]
onCategoryChange: () => void
onDelete?: (item: PackingItem) => Promise<void>
/** Enter on the filled '...' row, so the category's add field can take over the typing (#2825). */
onPlaceholderFilled?: () => void
bagTrackingEnabled?: boolean
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
@@ -49,7 +51,7 @@ interface ArtikelZeileProps {
}
}
export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, divider = false, drag }: ArtikelZeileProps) {
export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, onPlaceholderFilled, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, divider = false, drag }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
@@ -80,9 +82,12 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleSaveName = async () => {
const handleSaveName = async (viaEnter = false) => {
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
try {
await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false)
if (viaEnter && isPlaceholder) onPlaceholderFilled?.()
}
catch { toast.error(t('packing.toast.saveError')) }
}
@@ -188,8 +193,8 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
type="text" value={editName} autoFocus
placeholder={isPlaceholder ? '...' : undefined}
onChange={e => setEditName(e.target.value)}
onBlur={handleSaveName}
onKeyDown={e => { if (e.key === 'Enter') void handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
onBlur={() => void handleSaveName()}
onKeyDown={e => { if (e.key === 'Enter') void handleSaveName(true); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
/>
) : canEdit ? (
@@ -47,6 +47,14 @@ describe('aiConnectModel', () => {
expect(connectErrorKey(new Error('boom'))).toBe('assistant.error.generic');
});
it('FE-AI-CONNECT-MODEL-002b: tells an own server that answered without a model list from one that is down (#2808)', () => {
const noList = { response: { data: { code: 'provider_unavailable', reason: 'no_model_list' } } };
expect(connectErrorKey(noList)).toBe('assistant.connect.noModelList');
expect(connectErrorKey({ response: { data: { code: 'provider_unavailable', reason: 'other' } } })).toBe(
'assistant.error.providerDown'
);
});
it('FE-AI-CONNECT-MODEL-003: offers the way back into a broken connection, none for a working one', () => {
expect(repairModeOf(VIEW)).toBeNull();
expect(repairModeOf({ ...VIEW, status: 'error' })).toBe('key');
@@ -33,9 +33,20 @@ const CONNECT_ERRORS: Record<string, { messageKey: string }> = {
offline: { messageKey: 'assistant.error.offline' },
};
/** The line a failed connect shows. Takes `{provider}` where the line names one. */
/** What a refused connect says for the `reason` it carries next to its code, ahead of the code's line. */
const CONNECT_REASONS: Record<string, { messageKey: string }> = {
no_model_list: { messageKey: 'assistant.connect.noModelList' },
};
/**
* The line a failed connect shows. Takes `{provider}` where the line names one.
* An own server that answered without a model list is up, so it gets its own
* line about the address instead of "not responding" (#2808).
*/
export function connectErrorKey(err: unknown): string {
return CONNECT_ERRORS[refusalCode(err) ?? '']?.messageKey ?? 'assistant.error.generic';
const reason = (err as { response?: { data?: { reason?: unknown } } } | null)?.response?.data?.reason;
const byReason = typeof reason === 'string' ? CONNECT_REASONS[reason] : undefined;
return byReason?.messageKey ?? CONNECT_ERRORS[refusalCode(err) ?? '']?.messageKey ?? 'assistant.error.generic';
}
/** How each connection state reads on its pill. */
@@ -1,4 +1,4 @@
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-003
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-004
// The GPX preview as ACT-01 uses it: preset to the tour's type, a city walk
// imported as one, and when a track was recorded shown where it carried times.
import { fireEvent, render, screen } from '@testing-library/react';
@@ -47,6 +47,13 @@ describe('TourGpxPreviewDialog (ACT-01)', () => {
expect(recorded[0]).toHaveTextContent('50 min');
});
it('FE-GPX-PREVIEW-004: a track taken over from before Tours reads as one, not as a duplicate (#2810)', () => {
render(<TourGpxPreviewDialog gpxImport={gpxImport([track({ adopts_track: true })])} />);
expect(screen.getByTestId('gpx-preview-track')).not.toHaveAttribute('data-duplicate');
expect(screen.getByText(/tours\.import\.previewAdopts|becomes a tour/)).toBeInTheDocument();
expect(screen.queryByText(/tours\.import\.previewDuplicate|Already in the trip/)).toBeNull();
});
it('FE-GPX-PREVIEW-002: imports as the preset type, a paddle tour with its boat and water', () => {
const current = gpxImport([track()]);
render(
@@ -79,6 +79,7 @@ function PreviewTrackRow({
{track.name || name}
</span>
{track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>}
{track.adopts_track && <SoftPill>{t('tours.import.previewAdopts')}</SoftPill>}
</div>
<div className="flex flex-wrap items-center gap-1.5 ps-8" style={fs(11)}>
<span className={BADGE}>{formatDistance(track.distance_km, unit)}</span>
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
// Build server/assets/airports.json from OurAirports (davidmegginson.github.io/ourairports-data).
// License: Public Domain. Keeps large/medium airports with an IATA code; timezone derived from coords via tz-lookup.
// License: Public Domain. Keeps large/medium airports with an IATA code, small ones only with scheduled service;
// timezone derived from coords via tz-lookup.
import fs from 'node:fs';
import https from 'node:https';
import path from 'node:path';
@@ -91,7 +92,9 @@ for (let i = 1; i < rows.length; i++) {
if (!KEEP.has(r[TYPE])) continue;
const iata = r[IATA]?.trim().toUpperCase();
if (!iata || iata.length !== 3) continue;
if (r[SERVICE] !== 'yes') continue;
// The scheduled_service flag lags reality: Little Cayman (LYB) has daily flights and is
// marked "no". So it only filters the small fields, where it keeps every airstrip with a code out.
if (r[TYPE] === 'small_airport' && r[SERVICE] !== 'yes') continue;
const lat = Number(r[LAT]);
const lng = Number(r[LNG]);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
+1 -1
View File
@@ -5,7 +5,7 @@
"@typescript-eslint/no-require-imports": 18,
"@typescript-eslint/no-unsafe-function-type": 1,
"@typescript-eslint/no-unused-vars": 36,
"no-await-in-loop": 563,
"no-await-in-loop": 562,
"no-console": 235,
"no-empty": 6
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"scripts/db-call-graph.mjs": 1036,
"scripts/generate-entities.ts": 2259,
"src/db/repositories/Places.repository.ts": 1439,
"src/db/repositories/Places.repository.ts": 1438,
"src/db/repositories/Reservations.repository.ts": 1604,
"src/db/repositories/Trips.repository.ts": 1121,
"src/db/repositories/Users.repository.ts": 1445,
@@ -605,16 +605,15 @@ export class PlacesRepository extends TrekRepository<Places> {
* it was.
*/
async listDedupInputs(trip_id: string): Promise<{
name: string | null; lat: number | null; lng: number | null;
name: string | null; lat: number | null; lng: number | null; route_geometry: string | null;
google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null;
}[]> {
// route_geometry tells a track from a pin: a track to import matches only tracks (#2810).
return this.qb('p')
.select(['p.name', 'p.lat', 'p.lng', 'p.google_place_id', 'p.google_ftid', 'p.osm_id', 'p.amap_poi_id'])
.select(['p.name', 'p.lat', 'p.lng', 'p.route_geometry',
'p.google_place_id', 'p.google_ftid', 'p.osm_id', 'p.amap_poi_id'])
.where('p.trip_id = ?', [trip_id])
.execute<{
name: string | null; lat: number | null; lng: number | null;
google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null;
}[]>('all', false);
.execute<Awaited<ReturnType<PlacesRepository['listDedupInputs']>>>('all', false);
}
/**
@@ -91,7 +91,17 @@ const PROVIDER_MESSAGES: Partial<Record<AssistantErrorCode, string>> = {
provider_unavailable: 'The provider is not responding',
};
/** A provider's failure as the refusal a REST route answers with. */
/**
* A provider's failure as the refusal a REST route answers with. An own server
* that answered without a model list says so in `reason`, so the form can name
* the address instead of calling a server that is up unresponsive (#2808).
*/
export function providerRefusal(err: AiProviderError): DomainError {
if (err.providerCode === 'no_model_list') {
return new DomainError(REFUSAL_STATUS[err.code], 'The server answered, but sent no model list', {
code: err.code,
details: { code: err.code, reason: 'no_model_list' },
});
}
return aiRefusal(err.code, PROVIDER_MESSAGES[err.code] ?? 'The provider refused the request');
}
@@ -238,7 +238,15 @@ export class ModelCatalogService {
const { models } = await this.llmLocal.listModels(baseUrl ?? undefined, apiKey ?? undefined);
return models.map((m) => ({ id: m.name, label: m.name, supportsTools: true }));
} catch (err) {
if (err instanceof DomainError) throw new AiProviderError('provider_unavailable', err.getStatus(), true);
// The server's own answer decides what the admin reads: a refused key, a
// server that answered without a model list, or one that is not there.
// All three read as "not responding" before, which sent people looking
// at a server that was up (#2808).
if (err instanceof DomainError) {
if (err.code === 'llm_key_refused') throw new AiProviderError('invalid_key', err.getStatus(), false);
const reason = err.code === 'llm_no_model_list' ? 'no_model_list' : undefined;
throw new AiProviderError('provider_unavailable', err.getStatus(), true, undefined, reason);
}
throw err;
}
}
+73 -29
View File
@@ -26,6 +26,12 @@ const tagsResponseSchema = z.object({
/** The OpenAI-compatible list (`GET /v1/models`) that LM Studio, vLLM and llama.cpp serve. */
const openAiModelsSchema = z.object({ data: z.array(z.object({ id: z.string().optional() })) });
/** One place a server may list its models, and how its answer reads. */
interface ModelListSource {
url: string;
read: (body: unknown) => { name?: string; size?: number }[] | undefined;
}
/** The Bearer header the extraction sends, when a key is configured. */
function authHeader(apiKey: string | undefined): Record<string, string> {
return apiKey ? { authorization: `Bearer ${apiKey}` } : {};
@@ -43,11 +49,23 @@ function upstreamRefusal(status: number, apiKey: string | undefined, fallback: s
apiKey
? `Local LLM server rejected the API key (${status})`
: `Local LLM server requires an API key (${status}). Save the key for this address first.`,
{ code: 'llm_key_refused' },
);
}
return new DomainError(502, fallback);
}
/**
* A URL without its trailing slashes. Scanned off instead of `/\/+$/`-replaced:
* that pattern re-walks the slash run from every start position of the
* admin-supplied URL.
*/
function trimTrailingSlashes(url: string): string {
let end = url.length;
while (end > 0 && url.charCodeAt(end - 1) === 47 /* '/' */) end--;
return url.slice(0, end);
}
/**
* Admin helpers for managing a local OpenAI-compatible LLM server (Ollama).
* Talks to Ollama's *management* API (`/api/tags`, `/api/pull`), which lives at
@@ -72,11 +90,7 @@ export class LlmLocalService {
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new HttpException({ error: 'Base URL must be http(s)' }, 400);
}
// Trailing slashes are scanned off instead of `/\/+$/`-replaced: that pattern
// re-walks the slash run from every start position of the admin-supplied URL.
let end = raw.length;
while (end > 0 && raw.charCodeAt(end - 1) === 47 /* '/' */) end--;
return raw.slice(0, end).replace(/\/v1$/, '');
return trimTrailingSlashes(raw).replace(/\/v1$/, '');
}
/**
@@ -94,39 +108,69 @@ export class LlmLocalService {
}
/**
* List models already pulled on the local server. A server without Ollama's
* `/api/tags` (LM Studio, vLLM, llama.cpp) answers 404 there, so its
* OpenAI-compatible `/v1/models` is asked instead.
* List the models the server offers. Ollama's `/api/tags` is asked first, then
* the OpenAI-compatible list: at the address as entered (the one the chat
* calls `/chat/completions` on, `https://api.groq.com/openai/v1` say), then at
* `/v1/models` under the root for an address given without `/v1`.
*
* An answer only counts when it reads as a model list. LM Studio answers an
* unknown path with 200 and an error object, and a proxy may answer it with a
* login page, so stopping at the first answer that is not a 404 refused
* servers whose list was one request further (#2808).
*/
async listModels(
baseUrl: string | undefined,
apiKey?: string,
): Promise<{ models: { name: string; size: number }[] }> {
const root = this.ollamaRoot(baseUrl);
let res = await this.getModelList(`${root}/api/tags`, root, apiKey);
let openAi = false;
if (res.status === 404) {
const fallback = await this.getModelList(`${root}/v1/models`, root, apiKey);
if (fallback.ok) {
discardBody(res);
res = fallback;
openAi = true;
const asEntered = trimTrailingSlashes((baseUrl ?? '').trim()) || root;
const lists: ModelListSource[] = [
{ url: `${root}/api/tags`, read: (body) => tagsResponseSchema.safeParse(body).data?.models },
...[...new Set([`${asEntered}/models`, `${root}/v1/models`])].map((url) => ({
url,
read: (body: unknown) => openAiModelsSchema.safeParse(body).data?.data.map((m) => ({ name: m.id, size: 0 })),
})),
];
const seen = { firstStatus: 0, refusedStatus: 0 };
const listed = await this.firstModelList(lists, 0, root, apiKey, seen);
if (listed) return { models: listed.map((m) => ({ name: m.name ?? '', size: m.size ?? 0 })).filter((m) => m.name) };
if (seen.refusedStatus) throw upstreamRefusal(seen.refusedStatus, apiKey, '');
throw new DomainError(502, `Local LLM server sent no model list (${seen.firstStatus})`, {
code: 'llm_no_model_list',
});
}
/**
* The first of `lists`, from `index` on, that answers with a readable list,
* asked one after the other. Only the first request tells whether the server
* is there at all; a later one failing is that path, not the server.
*/
private async firstModelList(
lists: ModelListSource[],
index: number,
root: string,
apiKey: string | undefined,
seen: { firstStatus: number; refusedStatus: number },
): Promise<{ name?: string; size?: number }[] | null> {
const list = lists[index];
if (!list) return null;
let res: Response | null = null;
try {
res = await this.getModelList(list.url, root, apiKey);
} catch (err) {
if (index === 0) throw err;
}
if (res) {
seen.firstStatus ||= res.status;
if (res.status === 401 || res.status === 403) seen.refusedStatus ||= res.status;
if (res.ok) {
const listed = list.read(await readCappedJson<unknown>(res, MAX_LIST_BYTES).catch(() => undefined));
if (listed) return listed;
} else {
// Neither list exists: report the first answer, as before.
discardBody(fallback);
discardBody(res);
}
}
if (!res.ok) {
discardBody(res);
throw upstreamRefusal(res.status, apiKey, `Local LLM server error (${res.status})`);
}
const body = await readCappedJson<unknown>(res, MAX_LIST_BYTES);
const listed = openAi
? openAiModelsSchema.safeParse(body).data?.data.map((m) => ({ name: m.id, size: 0 }))
: tagsResponseSchema.safeParse(body).data?.models;
if (!listed) throw new DomainError(502, 'Local LLM server sent an unreadable model list');
const models = listed.map((m) => ({ name: m.name ?? '', size: m.size ?? 0 })).filter((m) => m.name);
return { models };
return this.firstModelList(lists, index + 1, root, apiKey, seen);
}
private async getModelList(url: string, root: string, apiKey: string | undefined): Promise<Response> {
@@ -1,5 +1,7 @@
/**
* Minimal Ollama native-API client used by the extraction router.
* Minimal Ollama native-API client used by the extraction router. A server that
* has no `/api/chat` gets the same extraction over OpenAI-compatible
* `/chat/completions` instead (see extractOpenAiCompatible below).
*
* Why not the OpenAI-compatible `/v1/chat/completions` path the rest of llm-parse uses?
* Ollama's `/v1` endpoint does NOT faithfully honour OpenAI's `response_format:{json_schema,strict}`
@@ -12,7 +14,9 @@
*/
import { readEnv } from '../../../app-config';
import { safeFetchLlm } from '../../../utils/ssrfGuard';
import { OpenAiCompatibleClient } from '../clients/openai-compatible.client';
import { parseLenientJson } from '../lenient-json';
import type { LlmExtractionFile } from '../llm-provider.interface';
export interface EnforcedExtractInput {
/** Ollama base URL — accepts the addon's `…/v1` form; the `/v1` suffix is stripped. */
@@ -40,6 +44,13 @@ export function toNativeBase(baseUrl: string): string {
return baseUrl.slice(0, end).replace(/\/v1$/, '');
}
/**
* The answers that say the server has no `/api/chat` at all, unless the body is
* Ollama's own 404 for a model it has not pulled (OLLAMA_MISSING_MODEL).
*/
const NOT_OLLAMA_STATUSES = new Set([404, 405, 501]);
const OLLAMA_MISSING_MODEL = /\bmodel\b.*\bnot found\b/i;
/**
* Run one schema-constrained chat completion against Ollama's native `/api/chat`.
* Returns the parsed JSON object (constrained to `schema`), or null if the request
@@ -85,10 +96,61 @@ export async function extractEnforced(input: EnforcedExtractInput): Promise<Reco
if (!res.ok) {
const detail = await res.text().catch(() => '');
if (NOT_OLLAMA_STATUSES.has(res.status) && !OLLAMA_MISSING_MODEL.test(detail))
return extractOpenAiCompatible(input);
throw new Error(`Ollama /api/chat failed (${res.status}): ${detail.slice(0, 200)}`);
}
const data = (await res.json()) as { message?: { content?: string } };
const data = (await res.json().catch(() => null)) as { message?: { content?: string } } | null;
// LM Studio answers a path it does not serve with 200 and an error object,
// so an answer without Ollama's `message` is a server of another kind too.
if (!data || typeof data.message !== 'object') return extractOpenAiCompatible(input);
const parsed = parseLenientJson(data.message?.content);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
}
/**
* The same extraction against an OpenAI-compatible server (Groq, vLLM,
* LM Studio, a LiteLLM proxy), which the "Own server" connection also names.
* It goes to `/chat/completions` at the address as entered, the endpoint the
* assistant chats on, through the client that already handles what such
* servers refuse (`json_schema`, `max_tokens`, `temperature`). The schema is
* asked for as a one-element list, the shape that client reads (#2806).
*/
async function extractOpenAiCompatible(input: EnforcedExtractInput): Promise<Record<string, unknown> | null> {
const [file, ...pageImages] = (input.images ?? []).map(imageFile);
const list = await new OpenAiCompatibleClient().extract({
// The shape goes into the prompt as well: the client's later rungs send no
// schema (json_object, then nothing), and json_object wants the word JSON.
prompt:
`${input.system} Answer with JSON only, as {"result": [object]} where the object follows this ` +
`JSON Schema: ${JSON.stringify(input.schema)}`,
jsonSchema: {
type: 'object',
properties: { result: { type: 'array', items: input.schema } },
required: ['result'],
},
rootKey: 'result',
userText: input.user,
model: input.model,
baseUrl: input.baseUrl,
apiKey: input.apiKey,
file,
pageImages,
});
const first = list[0];
return first && typeof first === 'object' ? first : null;
}
/** The first characters of a base64 image, per type. Anything else is sent as JPEG. */
const IMAGE_PREFIXES: [string, string][] = [
['iVBOR', 'image/png'],
['UklGR', 'image/webp'],
['R0lGOD', 'image/gif'],
];
/** A base64 image as a file, its type read from its first bytes. */
function imageFile(base64: string): LlmExtractionFile {
const mimeType = IMAGE_PREFIXES.find(([prefix]) => base64.startsWith(prefix))?.[1] ?? 'image/jpeg';
return { mimeType, data: Buffer.from(base64, 'base64') };
}
+17 -5
View File
@@ -86,11 +86,16 @@ export async function reclaimPhotoCache(
export interface DedupSet {
names: Set<string>;
/** The names of the places with a line: a track to import matches only these (#2810). Absent where none is read. */
trackNames?: Set<string>;
coords: Array<{ lat: number; lng: number }>;
/** Provider ids (google_place_id, google_ftid, osm_id, amap_poi_id) of the places already in the trip. */
externalIds: Set<string>;
}
/** A candidate for the dedup set; `track` when it is a line (a GPX track or route, a KML path). */
export type DedupCandidate = PlaceMatchCandidate & { track?: boolean };
/**
* Returns true if a candidate place is already represented in the dedup set.
*
@@ -104,12 +109,14 @@ export interface DedupSet {
* JavaScript's is not, and only unnamed rows contribute coordinates to a dedup
* set. `findDuplicatePlace` spells out where the two still answer differently.
*/
export function isPlaceDuplicate(candidate: PlaceMatchCandidate, dedup: DedupSet): boolean {
export function isPlaceDuplicate(candidate: DedupCandidate, dedup: DedupSet): boolean {
for (const strategy of placeMatchStrategies(candidate)) {
if (strategy.by === 'externalId') {
if (dedup.externalIds.has(strategy.id)) return true;
} else if (strategy.by === 'name') {
if (dedup.names.has(strategy.name)) return true;
// A track is the same as a track of that name, never as a sight of that name:
// a watch names a hike after the place it went to (#2810).
if ((candidate.track ? dedup.trackNames : dedup.names)?.has(strategy.name)) return true;
} else if (
dedup.coords.some(
(c) =>
@@ -130,29 +137,34 @@ export function isPlaceDuplicate(candidate: PlaceMatchCandidate, dedup: DedupSet
*/
export function dedupSetOf(
rows: ReadonlyArray<
{ name: string | null; lat: number | null; lng: number | null } & Parameters<typeof externalIdsOf>[0]
{ name: string | null; lat: number | null; lng: number | null; route_geometry?: string | null } & Parameters<
typeof externalIdsOf
>[0]
>,
): DedupSet {
const names = new Set<string>();
const trackNames = new Set<string>();
const coords: Array<{ lat: number; lng: number }> = [];
const externalIds = new Set<string>();
for (const row of rows) {
for (const id of externalIdsOf(row)) externalIds.add(id);
if (row.name) {
names.add(row.name.trim().toLowerCase());
if (row.route_geometry) trackNames.add(row.name.trim().toLowerCase());
} else if (row.lat != null && row.lng != null) {
coords.push({ lat: row.lat, lng: row.lng });
}
}
return { names, coords, externalIds };
return { names, trackNames, coords, externalIds };
}
/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */
export function trackInsertedInDedupSet(place: PlaceMatchCandidate, dedup: DedupSet): void {
export function trackInsertedInDedupSet(place: DedupCandidate, dedup: DedupSet): void {
for (const id of externalIdsOf(place)) dedup.externalIds.add(id);
const normalizedName = normalizePlaceName(place.name);
if (normalizedName) {
dedup.names.add(normalizedName);
if (place.track) (dedup.trackNames ??= new Set()).add(normalizedName);
} else if (place.lat != null && place.lng != null) {
dedup.coords.push({ lat: place.lat, lng: place.lng });
}
+5 -5
View File
@@ -758,7 +758,7 @@ export class PlacesService {
async gpxDuplicates(tripId: string, rows: PreparedGpxPlace[]): Promise<boolean[]> {
const dedup = await this.buildDedupSet(tripId);
return rows.map((wp) => {
const candidate = { name: wp.name, lat: wp.lat, lng: wp.lng };
const candidate = { name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry };
if (isPlaceDuplicate(candidate, dedup)) return true;
trackInsertedInDedupSet(candidate, dedup);
return false;
@@ -925,7 +925,7 @@ export class PlacesService {
// `findWithTagsAndRatings` INSIDE the caller's transaction, so the read
// sees the still-uncommitted row.
for (const wp of waypoints) {
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry }, dedup)) {
skipped++;
continue;
}
@@ -960,7 +960,7 @@ export class PlacesService {
});
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
created.push(place);
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry }, dedup);
}
return { places: created, count: created.length, skipped };
@@ -1033,7 +1033,7 @@ export class PlacesService {
const fallbackName = `Placemark ${fallbackIndex}`;
const name = parsedPlacemark.name || fallbackName;
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup)) {
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath }, dedup)) {
summary.skippedCount += 1;
dupCount++;
fallbackIndex += 1;
@@ -1079,7 +1079,7 @@ export class PlacesService {
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
created.push(place);
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup);
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath }, dedup);
summary.createdCount += 1;
fallbackIndex += 1;
}
@@ -70,7 +70,10 @@ export function activityFacts(
}
/** The times an import stores for a track, as the DATETIME columns hold them; NULL without. */
function storedTimes(row: PreparedGpxPlace | undefined): { started: DbTimestamp | null; ended: DbTimestamp | null } {
export function recordedTimesOf(row: PreparedGpxPlace | undefined): {
started: DbTimestamp | null;
ended: DbTimestamp | null;
} {
const times = row?.trackTimes;
return times
? { started: dbNow(new Date(times.startedAt)), ended: dbNow(new Date(times.endedAt)) }
@@ -93,7 +96,7 @@ export function trackTimesFor(
(row, at) => !taken.has(at) && row.name === place.name && row.lat === place.lat && row.lng === place.lng,
);
if (index >= 0) taken.add(index);
byPlace.set(place.id, storedTimes(rows[index]));
byPlace.set(place.id, recordedTimesOf(rows[index]));
}
return byPlace;
}
@@ -0,0 +1,42 @@
import { normalizePlaceName } from '@trek/shared';
/** A place of the trip as the places list reads it: enough to tell a track that is no tour yet. */
export interface TripPlaceForAdoption {
id: number;
name: string;
route_geometry?: string | null;
tour_place_id?: number | null;
}
/** A track of the trip a file row takes over: its place and the line it already holds. */
export interface AdoptedTrack {
id: number;
route_geometry: string;
}
/**
* The rows of a tour import that take over a track already in the trip, by
* row index: a place with a line and no tour yet, under the same name
* (trimmed, any case, the duplicate rule's own reading), one place per row
* and oldest first. A GPX file imported before Tours existed (4.x) left its
* tracks as plain places, and the name alone made every tour import of the
* same file a duplicate, with no tour to show for it (#2810).
*/
export function tracksToAdopt(
rows: ReadonlyArray<{ name: string }>,
places: ReadonlyArray<TripPlaceForAdoption>,
): Map<number, AdoptedTrack> {
const byName = new Map<string, AdoptedTrack[]>();
for (const place of [...places].sort((a, b) => a.id - b.id)) {
const key = normalizePlaceName(place.name);
if (!key || !place.route_geometry || place.tour_place_id != null) continue;
byName.set(key, [...(byName.get(key) ?? []), { id: place.id, route_geometry: place.route_geometry }]);
}
const adopt = new Map<number, AdoptedTrack>();
rows.forEach((row, index) => {
const key = normalizePlaceName(row.name);
const track = key ? byName.get(key)?.shift() : undefined;
if (track) adopt.set(index, track);
});
return adopt;
}
+140 -75
View File
@@ -16,7 +16,7 @@ import { toRowId } from '../common/row-id';
import { UnitOfWork } from '../database/unit-of-work';
import { PlaceImportService } from '../place-import/place-import.service';
import { PlacesService } from '../places/places.service';
import { toActivity, trackTimesFor } from './tour-activities.helpers';
import { recordedTimesOf, toActivity, trackTimesFor } from './tour-activities.helpers';
import {
copiedFacet,
copyStart,
@@ -29,6 +29,7 @@ import { readTourFile } from './tour-file-import.helpers';
import { provenanceSource, tourItemOf } from './tour-item.helpers';
import { TourPaddleCheckService } from './tour-paddle-check.service';
import { advanceTourRevision } from './tour-revision';
import { tracksToAdopt } from './tour-track-adoption';
import { toVisit } from './tour-visits.service';
import { checkTourCommand, firstPointOf, importFileName, keyedWaypoints, tourRouteFields } from './tour-write.helpers';
import { computeTourMetrics, localHashOf, parseRouteGeometry, type GeometryPoint } from './tours.helpers';
@@ -58,6 +59,30 @@ import {
wandererDifficultyToSac,
} from '@trek/shared';
/** `fn` over `items` one after the other, in order: the writes of one import share a transaction. */
async function inOrder<T, R>(items: readonly T[], fn: (item: T) => Promise<R>): Promise<R[]> {
return items.reduce<Promise<R[]>>(async (done, item) => [...(await done), await fn(item)], Promise.resolve([]));
}
/**
* What the type adds to each tour a file import makes: a bike tour and a run
* their profile, a paddle tour its boat and water, a ski track its discipline
* and technique (SKI-02). A run comes in without a pace and a ski track
* without a time: neither is worked out for them, so they stay unknown.
*/
function importedTypeFields(fields: TourGpxImportFields) {
return {
bike_profile: fields.bike_profile ?? null,
run_profile: fields.run_profile ?? null,
run_pace_seconds_per_km: null,
ski_discipline: fields.ski_discipline ?? null,
ski_technique: fields.ski_technique ?? null,
boat_type: fields.boat_type ?? null,
water_context: fields.water_context ?? null,
paddle_speed_kmh: null,
};
}
export interface ImportGpxAsTourResult {
tours: TourListItem[];
caution: boolean;
@@ -783,88 +808,45 @@ export class ToursService {
const { rows } = await readTourFile(this.placeImport, fileBuffer, fileName);
if (rows.length === 0) return null;
await this.assertTourTypeEnabled(fields.tour_type);
// What the type adds to each imported tour: a bike tour and a run their profile, a paddle tour its boat and water,
// a ski track its discipline and technique (SKI-02). A run comes in without a pace and a ski track without a
// time: neither is worked out for them, so they stay unknown.
const typeFields = {
bike_profile: fields.bike_profile ?? null,
run_profile: fields.run_profile ?? null,
run_pace_seconds_per_km: null,
ski_discipline: fields.ski_discipline ?? null,
ski_technique: fields.ski_technique ?? null,
boat_type: fields.boat_type ?? null,
water_context: fields.water_context ?? null,
paddle_speed_kmh: null,
};
const typeFields = importedTypeFields(fields);
const adopt = await this.adoptableTracks(tripId, rows);
const fresh = rows.filter((_, index) => !adopt.has(index));
const tours: TourListItem[] = [];
const adopted: number[] = [];
const result = await this.uow.transactional(async () => {
// A nested transactional, so the places and their facets commit together.
const imported = await this.places.importPreparedGpx(tripId, rows);
const times = trackTimesFor(rows, imported.places);
for (const place of imported.places) {
const recorded = times.get(place.id);
const metrics = computeTourMetrics(parseRouteGeometry(place.route_geometry));
const matchConfidence = metrics.hasElevation ? 1 : 0.3;
await this.toursRepo.insertTour({
place_id: place.id,
tour_type: fields.tour_type,
...typeFields,
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
match_confidence: matchConfidence,
max_hiking_difficulty: null,
origin: 'gpx',
origin_file_name: importFileName(fileName),
track_started_at: recorded?.started ?? null,
track_ended_at: recorded?.ended ?? null,
});
tours.push(
tourItemOf({
place_id: place.id,
name: place.name,
description: place.description ?? null,
website: place.website ?? null,
tour_type: fields.tour_type,
...typeFields,
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: matchConfidence,
max_hiking_difficulty: null,
derived_from_place_id: null,
origin: 'gpx',
portage_speed_kmh: null,
paddle_lake_style: null,
track_marks: null,
has_recorded_track: 0,
planned: 0,
has_waypoints: 0,
stage_ends: 0,
route_missing: 0,
revision: 1,
hiking_preferences: null,
track_started_at: recorded?.started ?? null,
track_ended_at: recorded?.ended ?? null,
}),
);
}
const imported =
fresh.length > 0 ? await this.places.importPreparedGpx(tripId, fresh) : { places: [], count: 0, skipped: 0 };
const times = trackTimesFor(fresh, imported.places);
tours.push(
...(await inOrder(imported.places, (place) =>
this.insertImportedTour(
place,
place.route_geometry ?? null,
times.get(place.id),
fields,
typeFields,
fileName,
),
)),
);
// A track from before Tours keeps its place and its line and becomes the
// tour; the file adds the recorded times 4.x never stored.
await inOrder([...adopt], async ([index, track]) => {
const place = await this.places.get(tripId, String(track.id));
if (!place) return;
const recorded = recordedTimesOf(rows[index]);
tours.push(await this.insertImportedTour(place, track.route_geometry, recorded, fields, typeFields, fileName));
adopted.push(place.id);
});
return imported;
});
if (result.places.length === 0) return { tours: [], caution: false, skipped: result.skipped };
if (tours.length === 0) return { tours: [], caution: false, skipped: result.skipped };
this.broadcastToursChanged(
tripId,
result.places.map((place) => place.id),
tours.map((tour) => tour.place_id),
socketId,
);
for (const place of result.places) {
@@ -874,9 +856,90 @@ export class ToursService {
this.logger.warn(`Committed GPX place ${place.id}: realtime notification failed`);
}
}
// The taken-over places now read as tours; the other clients get the row again.
await inOrder(adopted, async (id) => {
try {
const place = await this.places.get(tripId, String(id));
if (place) this.places.broadcast(tripId, 'place:updated', { place }, socketId);
} catch {
this.logger.warn(`Committed GPX tour on place ${id}: realtime notification failed`);
}
});
return { tours, caution: tours.some((t) => t.caution), skipped: result.skipped };
}
/** Which rows of a file take over a track of the trip from before Tours (#2810), read off the places list. */
private async adoptableTracks(tripId: string, rows: ReadonlyArray<{ name: string }>) {
return tracksToAdopt(rows, await this.places.list(tripId, {}));
}
/**
* The `tours` row for one place of a GPX import, with its metrics read off
* `geometry` and the recorded times, and the list item the import answers
* with. A new place and a track taken over from before Tours (#2810) get the
* same row.
*/
private async insertImportedTour(
place: { id: number; name: string; description?: string | null; website?: string | null },
geometry: string | null,
recorded: ReturnType<typeof recordedTimesOf> | undefined,
fields: TourGpxImportFields,
typeFields: ReturnType<typeof importedTypeFields>,
fileName: string | undefined,
): Promise<TourListItem> {
const metrics = computeTourMetrics(parseRouteGeometry(geometry));
const matchConfidence = metrics.hasElevation ? 1 : 0.3;
await this.toursRepo.insertTour({
place_id: place.id,
tour_type: fields.tour_type,
...typeFields,
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
match_confidence: matchConfidence,
max_hiking_difficulty: null,
origin: 'gpx',
origin_file_name: importFileName(fileName),
track_started_at: recorded?.started ?? null,
track_ended_at: recorded?.ended ?? null,
});
return tourItemOf({
place_id: place.id,
name: place.name,
description: place.description ?? null,
website: place.website ?? null,
tour_type: fields.tour_type,
...typeFields,
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: matchConfidence,
max_hiking_difficulty: null,
derived_from_place_id: null,
origin: 'gpx',
portage_speed_kmh: null,
paddle_lake_style: null,
track_marks: null,
has_recorded_track: 0,
planned: 0,
has_waypoints: 0,
stage_ends: 0,
route_missing: 0,
revision: 1,
hiking_preferences: null,
track_started_at: recorded?.started ?? null,
track_ended_at: recorded?.ended ?? null,
});
}
/**
* PAD-02: what a file would import, read with the import's own parser and
* duplicate rule and written nowhere. Null when the file holds no track or
@@ -887,6 +950,7 @@ export class ToursService {
const { rows, leftOut } = await readTourFile(this.placeImport, fileBuffer, fileName);
if (rows.length === 0) return null;
const duplicates = await this.places.gpxDuplicates(tripId, rows);
const adopt = await this.adoptableTracks(tripId, rows);
return {
tracks: rows.map((row, index) => {
const geometry = parseRouteGeometry(row.routeGeometry);
@@ -900,7 +964,8 @@ export class ToursService {
elevation_loss_m: metrics.elevationLossM,
started_at: row.trackTimes?.startedAt ?? null,
ended_at: row.trackTimes?.endedAt ?? null,
duplicate: duplicates[index] ?? false,
duplicate: !adopt.has(index) && (duplicates[index] ?? false),
...(adopt.has(index) ? { adopts_track: true } : {}),
};
}),
...(leftOut ? { left_out: leftOut } : {}),
+153
View File
@@ -0,0 +1,153 @@
/**
* An "Own server" that is OpenAI-compatible but not Ollama, end to end: the
* whole app against a fake server on loopback that only answers under
* `/openai/v1`, the way Groq does, and 404s every Ollama path.
*
* Covered: connecting it as the instance connection (#2808), the refusal that
* says the server answered without a model list, a refused key, and a booking
* read through it by AI parsing, which used to call Ollama's `/api/chat` and
* fail with the server's 404 (#2806).
*/
import { buildApp } from '../../src/bootstrap';
import { withRequestContext } from '../../src/nest/database/request-context';
import { LlmParseService } from '../../src/nest/llm-parse/llm-parse.service';
import { setAddonEnabled } from '../helpers/factories/settings';
import { makeUser } from '../helpers/factories/users';
import { resetRateLimits } from '../helpers/test-db';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// ── The fake server ──────────────────────────────────────────────────────────
/** How the fake answers: as Groq does, with 200 and a page for every path, or refusing every key. */
const server = { mode: 'groq' as 'groq' | 'html' | 'refuse', seen: [] as string[], chats: [] as unknown[] };
async function readBody(req: IncomingMessage): Promise<string> {
let text = '';
for await (const chunk of req) text += String(chunk);
return text;
}
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
async function answer(req: IncomingMessage, res: ServerResponse): Promise<void> {
server.seen.push(`${req.method} ${req.url}`);
const body = await readBody(req);
if (server.mode === 'refuse') return json(res, 401, { error: { message: 'Invalid API Key' } });
if (server.mode === 'html') {
res.writeHead(200, { 'content-type': 'text/html' });
res.end('<html><body>Sign in</body></html>');
return;
}
if (req.method === 'GET' && req.url === '/openai/v1/models') {
return json(res, 200, { object: 'list', data: [{ id: 'openai/gpt-oss-120b', object: 'model' }] });
}
if (req.method === 'POST' && req.url === '/openai/v1/chat/completions') {
server.chats.push(JSON.parse(body));
const hotel = {
name: 'Hotel Sakura',
address: '1-2-3 Asakusa, Tokyo',
checkin_time: '2026-11-02T15:00:00',
checkout_time: '2026-11-05T11:00:00',
};
const result = { result: [hotel] };
return json(res, 200, { choices: [{ message: { content: JSON.stringify(result) }, finish_reason: 'stop' }] });
}
json(res, 404, {
error: {
message: `Unknown request URL: ${req.method} ${req.url}.`,
type: 'invalid_request_error',
code: 'unknown_url',
},
});
}
// ── The app ──────────────────────────────────────────────────────────────────
let nestApp: INestApplication;
let orm: MikroORM;
let fake: HttpServer;
let adminId: number;
function connect(apiKey = 'gsk_test'): request.Test {
const { port } = fake.address() as AddressInfo;
return request(nestApp.getHttpServer())
.put('/api/admin/ai/connection')
.set('Cookie', sessionCookie(adminId))
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/openai/v1`, apiKey, model: 'openai/gpt-oss-120b' });
}
beforeAll(async () => {
fake = createServer((req, res) => {
answer(req, res).catch(() => res.destroy());
});
await new Promise<void>((resolve) => fake.listen(0, '127.0.0.1', resolve));
nestApp = await buildApp();
orm = nestApp.get(MikroORM);
await setAddonEnabled(orm, 'ai_assistant', true);
adminId = (await makeUser(orm, { role: 'admin' })).user.id;
});
beforeEach(async () => {
await resetRateLimits(nestApp);
server.mode = 'groq';
server.seen = [];
server.chats = [];
});
afterAll(async () => {
await nestApp.close();
await new Promise<void>((resolve) => fake.close(() => resolve()));
});
// ── Cases ────────────────────────────────────────────────────────────────────
describe('An OpenAI-compatible own server', () => {
it('AIOWN-E2E-001: connects, its models read from the list at the address as entered (#2808)', async () => {
const res = await connect();
expect(res.status).toBe(200);
expect(server.seen.slice(0, 2)).toEqual(['GET /openai/api/tags', 'GET /openai/v1/models']);
});
it('AIOWN-E2E-002: a server that answers without a model list is told apart from one that is down', async () => {
server.mode = 'html';
// Its own key, so the list the first case read is not taken from the catalog's cache.
const res = await connect('gsk_html');
expect(res.status).toBe(502);
expect(res.body).toMatchObject({ code: 'provider_unavailable', reason: 'no_model_list' });
});
it('AIOWN-E2E-003: a refused key reads as a refused key, not as a server that is down', async () => {
server.mode = 'refuse';
const res = await connect('wrong');
expect(res.status).toBe(422);
expect(res.body).toMatchObject({ code: 'invalid_key' });
});
it('AIOWN-E2E-004: AI parsing reads a booking through /chat/completions when /api/chat is missing (#2806)', async () => {
expect((await connect()).status).toBe(200);
await setAddonEnabled(orm, 'llm_parsing', true);
server.seen = [];
// Outside a request, so it needs its own ORM context, as a job would.
const booking = {
buffer: Buffer.from('Booking confirmation\nHotel Sakura, Tokyo\nCheck-in 2 Nov 2026, check-out 5 Nov 2026'),
originalName: 'booking.txt',
};
const out = await withRequestContext(orm, () => nestApp.get(LlmParseService).parse(booking, adminId));
expect(out.warnings.filter((w) => /failed/i.test(w))).toEqual([]);
expect(out.kiItems).toHaveLength(1);
expect(server.seen).toContain('POST /openai/api/chat');
expect(server.seen).toContain('POST /openai/v1/chat/completions');
const sent = server.chats[0] as { model: string; messages: { role: string }[] };
expect(sent.model).toBe('openai/gpt-oss-120b');
});
});
+45
View File
@@ -535,6 +535,51 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
expect(await countRows(orm, Tours)).toBe(1);
});
it('takes over a track from before Tours with the same name instead of skipping it (#2810)', async () => {
// 4.x stored a GPX track as a plain place: a line, no tours row.
const line = '[[49,12,100],[49.01,12.01,150],[49.02,12.02,120]]';
const legacy = (await makePlace(orm, 5, { name: 'Alpine Loop ', lat: 49, lng: 12, route_geometry: line })).id;
const elsewhere = (await makePlace(orm, 6, { name: 'Alpine loop', lat: 49, lng: 12, route_geometry: line })).id;
const preview = await previewGpx(OWNER, TRACK_GPX);
expect(preview.status).toBe(200);
expect(preview.body.tracks[0]).toMatchObject({ duplicate: false, adopts_track: true });
expect(await countRows(orm, Tours)).toBe(0);
const res = await importGpx(OWNER, TRACK_GPX);
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ caution: false, skipped: 0 });
expect(res.body.tours.map((tour: { place_id: number }) => tour.place_id)).toEqual([legacy]);
expect(await countRows(orm, Places, { trip: 5 })).toBe(1);
expect(await placeColumns(legacy, 'name', 'route_geometry')).toEqual({
name: 'Alpine Loop ',
route_geometry: line,
});
expect(await findRow(orm, Tours, { place: legacy })).toMatchObject({ tour_type: 'hike', origin: 'gpx' });
expect(await findRow(orm, Tours, { place: elsewhere })).toBeNull();
expect(broadcast.mock.calls.map((c) => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:updated']));
expect(broadcast.mock.calls.map((c) => c[1])).not.toContain('place:created');
// Now a tour, the same file is a duplicate again.
expect((await previewGpx(OWNER, TRACK_GPX)).body.tracks[0]).toMatchObject({ duplicate: true });
expect((await importGpx(OWNER, TRACK_GPX)).body).toEqual({ tours: [], caution: false, skipped: 1 });
});
it('imports a hike named after a sight of the trip instead of skipping it as a duplicate (#2810)', async () => {
// A watch names the hike after where it went, and that place is already planned.
const sight = (await makePlace(orm, 5, { name: 'alpine loop', lat: 47, lng: 11 })).id;
const preview = await previewGpx(OWNER, TRACK_GPX);
expect(preview.body.tracks[0]).toMatchObject({ name: 'Alpine loop', duplicate: false });
const res = await importGpx(OWNER, TRACK_GPX);
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ skipped: 0 });
expect(res.body.tours).toHaveLength(1);
expect(res.body.tours[0].place_id).not.toBe(sight);
expect(await placeColumns(sight, 'route_geometry')).toEqual({ route_geometry: null });
});
it('400 without a file, and for a file with no track or route', async () => {
const missing = await importGpx(OWNER, null);
expect(missing.status).toBe(400);
@@ -574,20 +574,21 @@ describe('PlacesRepository.listForGpx (PL29) and existsByImageUrl (PI1)', () =>
* by, the service-level dedup tests.
*/
describe('PlacesRepository.listDedupInputs (PL24)', () => {
it('PLACEREPO-048: projects exactly the seven dedup columns, scoped to the trip', async () => {
it('PLACEREPO-048: projects exactly the eight dedup columns, the line included (#2810), scoped to the trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
await insertRow(t.orm, Places, {
trip: trip.id, name: 'Louvre', lat: 48.86, lng: 2.34, google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1',
route_geometry: '[[48.86,2.34],[48.87,2.35]]',
});
await insertRow(t.orm, Places, { trip: trip.id, name: '', lat: 1.1, lng: 2.2 });
createPlace(testDb, other.id, { name: 'Not this trip' });
const rows = await places.listDedupInputs(String(trip.id));
expect(rows).toEqual([
{ name: 'Louvre', lat: 48.86, lng: 2.34, google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1' },
{ name: '', lat: 1.1, lng: 2.2, google_place_id: null, google_ftid: null, osm_id: null, amap_poi_id: null },
{ name: 'Louvre', lat: 48.86, lng: 2.34, route_geometry: '[[48.86,2.34],[48.87,2.35]]', google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1' },
{ name: '', lat: 1.1, lng: 2.2, route_geometry: null, google_place_id: null, google_ftid: null, osm_id: null, amap_poi_id: null },
]);
});
@@ -127,6 +127,16 @@ describe('constants', () => {
});
});
it('AIHLP-031b: an own server that answered without a model list says so in reason (#2808)', () => {
const err = providerRefusal(new AiProviderError('provider_unavailable', 404, true, undefined, 'no_model_list'));
expect(err.getStatus()).toBe(502);
expect(err.toBody()).toEqual({
error: 'The server answered, but sent no model list',
code: 'provider_unavailable',
reason: 'no_model_list',
});
});
it('AIHLP-032: the host a conversation goes to', () => {
expect(aiHostOf('anthropic', null)).toBe('api.anthropic.com');
expect(aiHostOf('chatgpt', null)).toBe('api.openai.com');
@@ -201,6 +201,19 @@ describe('ModelCatalogService', () => {
).rejects.toMatchObject({ code: 'provider_unavailable', status: 502 });
});
it('MCAT-007b: a custom server that refuses the key, or answers without a list, says so (#2808)', async () => {
const target = { kind: 'custom' as const, baseUrl: 'https://llm.example.org/v1', secret: 'k' };
llmLocal.listModels.mockRejectedValueOnce(new DomainError(502, 'rejected', { code: 'llm_key_refused' }));
await expect(catalog().list(target)).rejects.toMatchObject({ code: 'invalid_key', retryable: false });
llmLocal.listModels.mockRejectedValueOnce(new DomainError(502, 'no list', { code: 'llm_no_model_list' }));
await expect(catalog().list(target)).rejects.toMatchObject({
code: 'provider_unavailable',
providerCode: 'no_model_list',
});
llmLocal.listModels.mockRejectedValueOnce(new Error('boom'));
await expect(catalog().list(target)).rejects.toThrow('boom');
});
it('MCAT-008: the default model is a capable one, else the first listed', () => {
const list = (...ids: string[]) => ids.map((id) => ({ id, label: id, supportsTools: true }));
expect(pickDefaultModel('anthropic', list('claude-haiku', 'claude-sonnet-5', 'claude-opus'))).toBe(
@@ -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',
@@ -96,11 +96,63 @@ describe('LlmLocalService.listModels', () => {
expect(fetchFn.mock.calls[1][1].headers).toMatchObject({ authorization: 'Bearer k' });
});
it('keeps the 404 when the OpenAI model list is missing too', async () => {
mockFetch(async () => ({ ok: false, status: 404, body: null }));
it('says no model list came, with the first status, when no list exists anywhere', async () => {
const fetchFn = mockFetch(async () => ({ ok: false, status: 404, body: null }));
await expect(svc().listModels('http://nothing:1')).rejects.toMatchObject({
response: { error: 'Local LLM server error (404)' },
code: 'llm_no_model_list',
response: { error: 'Local LLM server sent no model list (404)' },
});
expect(fetchFn.mock.calls.map((c: unknown[]) => c[0])).toEqual([
'http://nothing:1/api/tags',
'http://nothing:1/models',
'http://nothing:1/v1/models',
]);
});
it('reads past an /api/tags that answers 200 without a model list, as LM Studio does (#2808)', async () => {
mockFetch(async (url?: string) =>
url?.endsWith('/api/tags')
? { ok: true, status: 200, json: async () => ({ error: 'Unexpected endpoint or method. (GET /api/tags)' }) }
: { ok: true, status: 200, json: async () => ({ data: [{ id: 'qwen3-8b' }] }) },
);
expect((await svc().listModels('http://lmstudio:1234/v1')).models).toEqual([{ name: 'qwen3-8b', size: 0 }]);
});
it('asks the address as entered when it does not end in /v1 (#2808)', async () => {
const fetchFn = mockFetch(async (url?: string) =>
url === 'https://owui.example.org/api/models'
? { ok: true, status: 200, json: async () => ({ data: [{ id: 'llama3.3' }] }) }
: { ok: false, status: 404, body: null },
);
expect((await svc().listModels('https://owui.example.org/api/', 'k')).models).toEqual([
{ name: 'llama3.3', size: 0 },
]);
expect(fetchFn.mock.calls.map((c: unknown[]) => c[0])).toEqual([
'https://owui.example.org/api/api/tags',
'https://owui.example.org/api/models',
]);
});
it('reports a refused key even when the refusal came from a later list', async () => {
mockFetch(async (url?: string) =>
url?.endsWith('/api/tags') ? { ok: false, status: 404, body: null } : { ok: false, status: 401, body: null },
);
await expect(svc().listModels('https://api.groq.com/openai/v1', 'bad')).rejects.toMatchObject({
code: 'llm_key_refused',
response: { error: 'Local LLM server rejected the API key (401)' },
});
});
it('skips a later list that cannot be reached, and a body that is not JSON', async () => {
safeFetchLlmMock
.mockImplementationOnce(async () => ({
ok: true,
status: 200,
json: async () => Promise.reject(new Error('html')),
}))
.mockImplementationOnce(() => Promise.reject(new Error('ECONNRESET')))
.mockImplementationOnce(async () => ({ ok: true, status: 200, json: async () => ({ data: [{ id: 'm' }] }) }));
expect((await svc().listModels('http://vllm:8000')).models).toEqual([{ name: 'm', size: 0 }]);
});
it('502s when the server is unreachable', async () => {
@@ -95,4 +95,79 @@ describe('extractEnforced', () => {
mockFetch(() => jsonResponse({ error: 'model not found' }, false, 404));
await expect(extractEnforced(INPUT)).rejects.toThrow(/Ollama \/api\/chat failed \(404\)/);
});
it('throws for a server error without trying another path', async () => {
const fetchFn = mockFetch(() => jsonResponse({ error: 'boom' }, false, 500));
await expect(extractEnforced(INPUT)).rejects.toThrow(/failed \(500\)/);
expect(fetchFn).toHaveBeenCalledTimes(1);
});
});
describe('extractEnforced on an OpenAI-compatible server (#2806)', () => {
const GROQ = { ...INPUT, baseUrl: 'https://api.groq.com/openai/v1', model: 'openai/gpt-oss-120b', apiKey: 'gsk' };
const chatAnswer = (content: string) => jsonResponse({ choices: [{ message: { content } }] });
const unknownUrl = (url: string) =>
jsonResponse(
{ error: { message: `Unknown request URL: POST ${new URL(url).pathname}.`, code: 'unknown_url' } },
false,
404,
);
it('sends the extraction to /chat/completions at the address as entered when /api/chat is missing', async () => {
const fetchFn = mockFetch((url = '') =>
url.endsWith('/api/chat') ? unknownUrl(url) : chatAnswer('{"result":[{"name":"Hotel"}]}'),
);
expect(await extractEnforced(GROQ)).toEqual({ name: 'Hotel' });
expect(fetchFn.mock.calls.map((c) => c[0])).toEqual([
'https://api.groq.com/openai/api/chat',
'https://api.groq.com/openai/v1/chat/completions',
]);
const init = fetchFn.mock.calls[1][1] as RequestInit;
expect((init.headers as Record<string, string>).authorization).toBe('Bearer gsk');
const body = JSON.parse(init.body as string);
expect(body.model).toBe('openai/gpt-oss-120b');
expect(body.response_format.json_schema.schema).toEqual({
type: 'object',
properties: { result: { type: 'array', items: { type: 'object' } } },
required: ['result'],
});
expect(body.messages[0].content).toContain('{"result": [object]}');
expect(body.messages[1].content[0].text).toBe('doc');
});
it('falls back on a 405 or 501 too, and on a 200 that is not an Ollama answer (LM Studio)', async () => {
const fallsBackAfter = async (first: Response) => {
mockFetch((url = '') => (url.endsWith('/api/chat') ? first : chatAnswer('{"result":[{"a":1}]}')));
expect(await extractEnforced(GROQ)).toEqual({ a: 1 });
};
await fallsBackAfter(jsonResponse({}, false, 405));
await fallsBackAfter(jsonResponse({}, false, 501));
await fallsBackAfter(jsonResponse({ error: 'Unexpected endpoint' }));
});
it('answers null when the server returns no object', async () => {
mockFetch((url = '') => (url.endsWith('/api/chat') ? unknownUrl(url) : chatAnswer('{"result":[]}')));
expect(await extractEnforced(GROQ)).toBeNull();
});
it('passes the images on as image parts with their type', async () => {
const fetchFn = mockFetch((url = '') =>
url.endsWith('/api/chat') ? unknownUrl(url) : chatAnswer('{"result":[{}]}'),
);
await extractEnforced({ ...GROQ, images: ['iVBORw0K', '/9j/4AAQ', 'UklGRiQA', 'R0lGODlh'] });
const parts = JSON.parse((fetchFn.mock.calls[1][1] as RequestInit).body as string).messages[1].content;
expect(parts.slice(1).map((p: { image_url: { url: string } }) => p.image_url.url.split(';')[0])).toEqual([
'data:image/png',
'data:image/jpeg',
'data:image/webp',
'data:image/gif',
]);
});
it('reports the OpenAI-compatible failure when that path fails too', async () => {
mockFetch((url = '') =>
url.endsWith('/api/chat') ? unknownUrl(url) : jsonResponse({ error: 'bad model' }, false, 404),
);
await expect(extractEnforced(GROQ)).rejects.toThrow(/LLM request failed \(404\)/);
});
});
@@ -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;`);
+2
View File
@@ -39,6 +39,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'فتح المساعد',
'assistant.connect.badKey': 'هذا المفتاح لا يعمل.',
'assistant.connect.unknownModel': 'هذا النموذج غير متاح هنا.',
'assistant.connect.noModelList':
'استجاب الخادم، لكنه لم يرسل قائمة بالنماذج. تحقّق من العنوان، فهو ينتهي عادةً بـ /v1.',
'assistant.connect.getKey': 'الحصول على مفتاح',
'assistant.connect.hintAnthropic': 'تتضمن باقتا Claude Max وTeam رصيدًا لـ API.',
'assistant.connect.hintOpenai': 'أنشئ مفتاحًا في لوحة تحكم OpenAI.',
+1 -1
View File
@@ -321,7 +321,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'تخطيط تلقائي',
'journey.studio.export': 'تصدير',
'journey.studio.day': 'اليوم {day}',
'journey.studio.stations': 'المحطات',
'journey.studio.stations': 'على الطريق',
'journey.studio.peersHere': 'هنا',
'journey.studio.folioAuto': 'تلقائي',
'journey.studio.exportLayout': 'التخطيط',
+1
View File
@@ -969,6 +969,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'لم يُعثر على ملف KML في {name}',
'tours.import.archiveTooLargeIn': '{name} كبير جدًا بعد فك الضغط',
'tours.import.previewDuplicate': 'موجود بالفعل في الرحلة',
'tours.import.previewAdopts': 'مسار موجود في الرحلة، سيصبح جولة',
'tours.import.previewAllDuplicates': 'كل المسارات موجودة بالفعل في الرحلة، فلا شيء للاستيراد.',
'tours.import.previewAs': 'الاستيراد كـ',
'tours.import.previewConfirm': 'استيراد {count} جولة',
+2
View File
@@ -41,6 +41,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Köməkçini aç',
'assistant.connect.badKey': 'Bu açar işləmir.',
'assistant.connect.unknownModel': 'Bu model burada təklif olunmur.',
'assistant.connect.noModelList':
'Server cavab verdi, amma model siyahısı göndərmədi. Ünvanı yoxlayın, adətən /v1 ilə bitir.',
'assistant.connect.getKey': 'Açar əldə et',
'assistant.connect.hintAnthropic': 'Claude Max və Team planlarına API kreditləri daxildir.',
'assistant.connect.hintOpenai': 'OpenAI panelinizdə açar yaradın.',
+1 -1
View File
@@ -323,7 +323,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Avtomatik tərtibat',
'journey.studio.export': 'İxrac et',
'journey.studio.day': 'GÜN {day}',
'journey.studio.stations': 'Dayanacaqlar',
'journey.studio.stations': 'Yol boyunca',
'journey.studio.peersHere': 'burada',
'journey.studio.folioAuto': 'Avtomatik',
+1
View File
@@ -958,6 +958,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': '{name} içində KML faylı tapılmadı',
'tours.import.archiveTooLargeIn': '{name} açıldıqdan sonra çox böyükdür',
'tours.import.previewDuplicate': 'Artıq səyahətdədir',
'tours.import.previewAdopts': 'Artıq səyahətdə trek kimi var, tura çevriləcək',
'tours.import.previewAllDuplicates': 'Bütün treklər artıq səyahətdədir, idxal ediləcək heç nə yoxdur.',
'tours.import.previewAs': 'Bu kimi idxal et',
'tours.import.previewConfirm': '{count} tur idxal et',
+2
View File
@@ -41,6 +41,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Abrir assistente',
'assistant.connect.badKey': 'Esta chave não funciona.',
'assistant.connect.unknownModel': 'Este modelo não está disponível aqui.',
'assistant.connect.noModelList':
'O servidor respondeu, mas não enviou uma lista de modelos. Confira o endereço, ele costuma terminar em /v1.',
'assistant.connect.getKey': 'Obter uma chave',
'assistant.connect.hintAnthropic': 'Claude Max e Team incluem créditos de API.',
'assistant.connect.hintOpenai': 'Crie uma chave no seu painel da OpenAI.',
+1 -1
View File
@@ -300,7 +300,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Layout automático',
'journey.studio.export': 'Exportar',
'journey.studio.day': 'DIA {day}',
'journey.studio.stations': 'Etapas',
'journey.studio.stations': 'Pelo caminho',
'journey.studio.peersHere': 'aqui',
'journey.studio.folioAuto': 'Automático',
'journey.studio.exportLayout': 'Layout', // same-as-en
+1
View File
@@ -964,6 +964,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Nenhum arquivo KML encontrado em {name}',
'tours.import.archiveTooLargeIn': '{name} é grande demais depois de descompactado',
'tours.import.previewDuplicate': 'Já está na viagem',
'tours.import.previewAdopts': 'Já é uma trilha na viagem, vira um passeio',
'tours.import.previewAllDuplicates': 'Todas as trilhas já estão na viagem, não há nada para importar.',
'tours.import.previewAs': 'Importar como',
'tours.import.previewConfirm': 'Importar {count} passeios',
+2
View File
@@ -41,6 +41,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Obre l’assistent',
'assistant.connect.badKey': 'Aquesta clau no funciona.',
'assistant.connect.unknownModel': 'Aquest model no s’ofereix aquí.',
'assistant.connect.noModelList':
'El servidor ha respost, però no ha enviat cap llista de models. Revisa l’adreça, normalment acaba en /v1.',
'assistant.connect.getKey': 'Obtén una clau',
'assistant.connect.hintAnthropic': 'Claude Max i Team inclouen crèdits d’API.',
'assistant.connect.hintOpenai': 'Crea una clau al teu tauler d’OpenAI.',
+1 -1
View File
@@ -298,7 +298,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Disseny automàtic',
'journey.studio.export': 'Exporta',
'journey.studio.day': 'DIA {day}',
'journey.studio.stations': 'Etapes',
'journey.studio.stations': 'Pel camí',
'journey.studio.peersHere': 'aquí',
'journey.studio.folioAuto': 'Automàtic',
'journey.studio.exportLayout': 'Disposició',
+1
View File
@@ -966,6 +966,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': "No s'ha trobat cap fitxer KML a {name}",
'tours.import.archiveTooLargeIn': '{name} és massa gran un cop descomprimit',
'tours.import.previewDuplicate': 'Ja és al viatge',
'tours.import.previewAdopts': 'Ja és una traça al viatge, es converteix en excursió',
'tours.import.previewAllDuplicates': 'Tots els tracks ja són al viatge, no hi ha res a importar.',
'tours.import.previewAs': 'Importa com a',
'tours.import.previewConfirm': 'Importa {count} rutes',
+2
View File
@@ -40,6 +40,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Otevřít asistenta',
'assistant.connect.badKey': 'Tento klíč nefunguje.',
'assistant.connect.unknownModel': 'Tento model tu není k dispozici.',
'assistant.connect.noModelList':
'Server odpověděl, ale neposlal seznam modelů. Zkontrolujte adresu, obvykle končí na /v1.',
'assistant.connect.getKey': 'Získat klíč',
'assistant.connect.hintAnthropic': 'Claude Max a Team zahrnují kredity API.',
'assistant.connect.hintOpenai': 'Vytvoř klíč na svém panelu OpenAI.',
+1 -1
View File
@@ -304,7 +304,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Automatické rozvržení',
'journey.studio.export': 'Exportovat',
'journey.studio.day': 'DEN {day}',
'journey.studio.stations': 'Zastávky',
'journey.studio.stations': 'Cestou',
'journey.studio.peersHere': 'zde',
'journey.studio.folioAuto': 'Automaticky',
'journey.studio.exportLayout': 'Rozvržení',
+1
View File
@@ -952,6 +952,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'V souboru {name} není žádný soubor KML',
'tours.import.archiveTooLargeIn': 'Soubor {name} je po rozbalení příliš velký',
'tours.import.previewDuplicate': 'Už je v cestě',
'tours.import.previewAdopts': 'Už je v cestě jako trasa, stane se výletem',
'tours.import.previewAllDuplicates': 'Všechny trasy už v cestě jsou, není co importovat.',
'tours.import.previewAs': 'Importovat jako',
'tours.import.previewConfirm': 'Importovat {count} túr',
+2
View File
@@ -42,6 +42,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Assistent öffnen',
'assistant.connect.badKey': 'Dieser Schlüssel funktioniert nicht.',
'assistant.connect.unknownModel': 'Dieses Modell wird hier nicht angeboten.',
'assistant.connect.noModelList':
'Der Server antwortet, schickt aber keine Modellliste. Prüfe die Adresse, meist endet sie auf /v1.',
'assistant.connect.getKey': 'Schlüssel holen',
'assistant.connect.hintAnthropic': 'Claude Max und Team enthalten API-Guthaben.',
'assistant.connect.hintOpenai': 'Leg einen Schlüssel in deinem OpenAI-Dashboard an.',
+1 -1
View File
@@ -300,7 +300,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Automatisch anordnen',
'journey.studio.export': 'Exportieren',
'journey.studio.day': 'TAG {day}',
'journey.studio.stations': 'Stationen',
'journey.studio.stations': 'Unterwegs',
'journey.studio.peersHere': 'hier',
'journey.studio.folioAuto': 'Automatisch',
'journey.studio.exportLayout': 'Layout', // same-as-en
+1
View File
@@ -975,6 +975,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Keine KML-Datei in {name} gefunden',
'tours.import.archiveTooLargeIn': '{name} ist entpackt zu groß',
'tours.import.previewDuplicate': 'Schon in der Reise',
'tours.import.previewAdopts': 'Schon als Track in der Reise, wird zur Tour',
'tours.import.previewAllDuplicates': 'Alle Tracks sind schon in der Reise, es gibt nichts zu importieren.',
'tours.import.previewAs': 'Importieren als',
'tours.import.previewConfirm': '{count} Touren importieren',
+2
View File
@@ -40,6 +40,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Open assistant',
'assistant.connect.badKey': 'This key doesn’t work.',
'assistant.connect.unknownModel': 'This model isn’t offered here.',
'assistant.connect.noModelList':
'The server answered, but sent no list of models. Check the address, it usually ends in /v1.',
'assistant.connect.getKey': 'Get a key',
'assistant.connect.hintAnthropic': 'Claude Max and Team include API credits.',
'assistant.connect.hintOpenai': 'Create a key in your OpenAI dashboard.',
+1 -1
View File
@@ -297,7 +297,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Auto layout',
'journey.studio.export': 'Export',
'journey.studio.day': 'DAY {day}',
'journey.studio.stations': 'Stations',
'journey.studio.stations': 'Along the way',
'journey.studio.peersHere': 'here',
'journey.studio.folioAuto': 'Automatic',
'journey.studio.exportLayout': 'Layout',
+1
View File
@@ -946,6 +946,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'No KML file found in {name}',
'tours.import.archiveTooLargeIn': '{name} is too large once unpacked',
'tours.import.previewDuplicate': 'Already in the trip',
'tours.import.previewAdopts': 'Already a track in the trip, becomes a tour',
'tours.import.previewAllDuplicates': 'Every track is already in the trip, so there is nothing to import.',
'tours.import.previewAs': 'Import as',
'tours.import.previewConfirm': 'Import {count} tours',
+2
View File
@@ -40,6 +40,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Abrir asistente',
'assistant.connect.badKey': 'Esta clave no funciona.',
'assistant.connect.unknownModel': 'Este modelo no está disponible aquí.',
'assistant.connect.noModelList':
'El servidor respondió, pero no envió una lista de modelos. Revisa la dirección, suele terminar en /v1.',
'assistant.connect.getKey': 'Obtener una clave',
'assistant.connect.hintAnthropic': 'Claude Max y Team incluyen créditos de API.',
'assistant.connect.hintOpenai': 'Crea una clave en tu panel de OpenAI.',
+1 -1
View File
@@ -299,7 +299,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Diseño automático',
'journey.studio.export': 'Exportar',
'journey.studio.day': 'DÍA {day}',
'journey.studio.stations': 'Etapas',
'journey.studio.stations': 'Por el camino',
'journey.studio.peersHere': 'aquí',
'journey.studio.folioAuto': 'Automático',
'journey.studio.exportLayout': 'Diseño',
+1
View File
@@ -963,6 +963,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'No hay ningún archivo KML en {name}',
'tours.import.archiveTooLargeIn': '{name} es demasiado grande una vez descomprimido',
'tours.import.previewDuplicate': 'Ya está en el viaje',
'tours.import.previewAdopts': 'Ya es un track en el viaje, pasa a ser una excursión',
'tours.import.previewAllDuplicates': 'Todos los tracks ya están en el viaje, no hay nada que importar.',
'tours.import.previewAs': 'Importar como',
'tours.import.previewConfirm': 'Importar {count} rutas',
+2
View File
@@ -41,6 +41,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Ava assistent',
'assistant.connect.badKey': 'See võti ei tööta.',
'assistant.connect.unknownModel': 'Seda mudelit siin ei pakuta.',
'assistant.connect.noModelList':
'Server vastas, kuid ei saatnud mudelite loendit. Kontrolli aadressi, tavaliselt lõpeb see /v1-ga.',
'assistant.connect.getKey': 'Hangi võti',
'assistant.connect.hintAnthropic': 'Claude Max ja Team sisaldavad API-krediiti.',
'assistant.connect.hintOpenai': 'Loo võti oma OpenAI juhtpaneelil.',
+1 -1
View File
@@ -299,7 +299,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Automaatne paigutus',
'journey.studio.export': 'Eksport',
'journey.studio.day': '{day}. PÄEV',
'journey.studio.stations': 'Peatused',
'journey.studio.stations': 'Teel',
'journey.studio.peersHere': 'siin',
'journey.studio.folioAuto': 'Automaatne',
'journey.studio.exportLayout': 'Paigutus',
+1
View File
@@ -948,6 +948,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Failis {name} pole KML-faili',
'tours.import.archiveTooLargeIn': 'Fail {name} on lahtipakituna liiga suur',
'tours.import.previewDuplicate': 'Juba reisil',
'tours.import.previewAdopts': 'Juba reisil rajana, muutub retkeks',
'tours.import.previewAllDuplicates': 'Kõik rajad on juba reisil, pole midagi importida.',
'tours.import.previewAs': 'Impordi kui',
'tours.import.previewConfirm': 'Impordi {count} matka',
+2
View File
@@ -41,6 +41,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Ouvrir l’assistant',
'assistant.connect.badKey': 'Cette clé ne fonctionne pas.',
'assistant.connect.unknownModel': 'Ce modèle n’est pas proposé ici.',
'assistant.connect.noModelList':
'Le serveur a répondu, mais sans liste de modèles. Vérifiez l’adresse, elle se termine généralement par /v1.',
'assistant.connect.getKey': 'Obtenir une clé',
'assistant.connect.hintAnthropic': 'Claude Max et Team incluent des crédits API.',
'assistant.connect.hintOpenai': 'Créez une clé dans votre tableau de bord OpenAI.',
+1 -1
View File
@@ -299,7 +299,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Mise en page auto',
'journey.studio.export': 'Exporter',
'journey.studio.day': 'JOUR {day}',
'journey.studio.stations': 'Étapes',
'journey.studio.stations': 'En chemin',
'journey.studio.peersHere': 'ici',
'journey.studio.folioAuto': 'Automatique',
'journey.studio.exportLayout': 'Mise en page',
+1
View File
@@ -981,6 +981,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Aucun fichier KML trouvé dans {name}',
'tours.import.archiveTooLargeIn': '{name} est trop volumineux une fois décompressé',
'tours.import.previewDuplicate': 'Déjà dans le voyage',
'tours.import.previewAdopts': 'Déjà une trace dans le voyage, devient une sortie',
'tours.import.previewAllDuplicates': 'Toutes les traces sont déjà dans le voyage : il n’y a rien à importer.',
'tours.import.previewAs': 'Importer comme',
'tours.import.previewConfirm': 'Importer {count} sorties',
+2
View File
@@ -42,6 +42,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Άνοιγμα βοηθού',
'assistant.connect.badKey': 'Αυτό το κλειδί δεν λειτουργεί.',
'assistant.connect.unknownModel': 'Αυτό το μοντέλο δεν προσφέρεται εδώ.',
'assistant.connect.noModelList':
'Ο διακομιστής απάντησε, αλλά δεν έστειλε λίστα μοντέλων. Ελέγξτε τη διεύθυνση, συνήθως τελειώνει σε /v1.',
'assistant.connect.getKey': 'Λήψη κλειδιού',
'assistant.connect.hintAnthropic': 'Τα Claude Max και Team περιλαμβάνουν μονάδες API.',
'assistant.connect.hintOpenai': 'Δημιουργήστε ένα κλειδί στον πίνακα ελέγχου του OpenAI.',
+1 -1
View File
@@ -302,7 +302,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Αυτόματη διάταξη',
'journey.studio.export': 'Εξαγωγή',
'journey.studio.day': 'ΗΜΕΡΑ {day}',
'journey.studio.stations': 'Στάσεις',
'journey.studio.stations': 'Στη διαδρομή',
'journey.studio.peersHere': 'εδώ',
'journey.studio.folioAuto': 'Αυτόματα',
'journey.studio.exportLayout': 'Διάταξη',
+1
View File
@@ -986,6 +986,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Δεν βρέθηκε αρχείο KML στο {name}',
'tours.import.archiveTooLargeIn': 'Το {name} είναι πολύ μεγάλο μετά την αποσυμπίεση',
'tours.import.previewDuplicate': 'Υπάρχει ήδη στο ταξίδι',
'tours.import.previewAdopts': 'Υπάρχει ήδη ως διαδρομή στο ταξίδι, γίνεται εκδρομή',
'tours.import.previewAllDuplicates': 'Όλα τα ίχνη υπάρχουν ήδη στο ταξίδι, δεν υπάρχει τίποτα για εισαγωγή.',
'tours.import.previewAs': 'Εισαγωγή ως',
'tours.import.previewConfirm': 'Εισαγωγή {count} διαδρομών',
+2
View File
@@ -41,6 +41,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Asszisztens megnyitása',
'assistant.connect.badKey': 'Ez a kulcs nem működik.',
'assistant.connect.unknownModel': 'Ez a modell itt nem érhető el.',
'assistant.connect.noModelList':
'A szerver válaszolt, de nem küldött modellistát. Ellenőrizd a címet, általában /v1-re végződik.',
'assistant.connect.getKey': 'Kulcs igénylése',
'assistant.connect.hintAnthropic': 'A Claude Max és Team csomag API-kreditet is tartalmaz.',
'assistant.connect.hintOpenai': 'Hozz létre egy kulcsot az OpenAI irányítópultján.',
+1 -1
View File
@@ -298,7 +298,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Automatikus elrendezés',
'journey.studio.export': 'Exportálás',
'journey.studio.day': '{day}. NAP',
'journey.studio.stations': 'Állomások',
'journey.studio.stations': 'Útközben',
'journey.studio.peersHere': 'itt',
'journey.studio.folioAuto': 'Automatikus',
'journey.studio.exportLayout': 'Elrendezés',
+1
View File
@@ -963,6 +963,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'A(z) {name} nem tartalmaz KML-fájlt',
'tours.import.archiveTooLargeIn': 'A(z) {name} kicsomagolva túl nagy',
'tours.import.previewDuplicate': 'Már az utazásban van',
'tours.import.previewAdopts': 'Már nyomvonalként szerepel az utazásban, túra lesz belőle',
'tours.import.previewAllDuplicates': 'Minden nyomvonal már az utazásban van, nincs mit importálni.',
'tours.import.previewAs': 'Importálás mint',
'tours.import.previewConfirm': '{count} túra importálása',
+2
View File
@@ -41,6 +41,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Buka asisten',
'assistant.connect.badKey': 'Kunci ini tidak berfungsi.',
'assistant.connect.unknownModel': 'Model ini tidak tersedia di sini.',
'assistant.connect.noModelList':
'Server menjawab, tetapi tidak mengirim daftar model. Periksa alamatnya, biasanya diakhiri dengan /v1.',
'assistant.connect.getKey': 'Dapatkan kunci',
'assistant.connect.hintAnthropic': 'Claude Max dan Team sudah termasuk kredit API.',
'assistant.connect.hintOpenai': 'Buat kunci di dasbor OpenAI kamu.',
+1 -1
View File
@@ -291,7 +291,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Tata letak otomatis',
'journey.studio.export': 'Ekspor',
'journey.studio.day': 'HARI {day}',
'journey.studio.stations': 'Persinggahan',
'journey.studio.stations': 'Sepanjang jalan',
'journey.studio.peersHere': 'di sini',
'journey.studio.folioAuto': 'Otomatis',
'journey.studio.exportLayout': 'Tata letak',
+1
View File
@@ -954,6 +954,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Tidak ada berkas KML di {name}',
'tours.import.archiveTooLargeIn': '{name} terlalu besar setelah diekstrak',
'tours.import.previewDuplicate': 'Sudah ada di perjalanan',
'tours.import.previewAdopts': 'Sudah ada sebagai jalur di perjalanan, menjadi tur',
'tours.import.previewAllDuplicates': 'Semua jejak sudah ada di perjalanan, tidak ada yang perlu diimpor.',
'tours.import.previewAs': 'Impor sebagai',
'tours.import.previewConfirm': 'Impor {count} tur',
+2
View File
@@ -41,6 +41,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Apri l’assistente',
'assistant.connect.badKey': 'Questa chiave non funziona.',
'assistant.connect.unknownModel': 'Questo modello non è disponibile qui.',
'assistant.connect.noModelList':
'Il server ha risposto, ma non ha inviato un elenco di modelli. Controlla l’indirizzo, di solito termina con /v1.',
'assistant.connect.getKey': 'Ottieni una chiave',
'assistant.connect.hintAnthropic': 'Claude Max e Team includono crediti API.',
'assistant.connect.hintOpenai': 'Crea una chiave nella dashboard di OpenAI.',
+1 -1
View File
@@ -298,7 +298,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Layout automatico',
'journey.studio.export': 'Esporta',
'journey.studio.day': 'GIORNO {day}',
'journey.studio.stations': 'Tappe',
'journey.studio.stations': 'Lungo la strada',
'journey.studio.peersHere': 'qui',
'journey.studio.folioAuto': 'Automatico',
'journey.studio.exportLayout': 'Impaginazione',
+1
View File
@@ -970,6 +970,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Nessun file KML trovato in {name}',
'tours.import.archiveTooLargeIn': '{name} è troppo grande una volta decompresso',
'tours.import.previewDuplicate': 'Già nel viaggio',
'tours.import.previewAdopts': 'Già una traccia nel viaggio, diventa un’escursione',
'tours.import.previewAllDuplicates': 'Tutte le tracce sono già nel viaggio, non c’è nulla da importare.',
'tours.import.previewAs': 'Importa come',
'tours.import.previewConfirm': 'Importa {count} tour',
+2
View File
@@ -41,6 +41,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'アシスタントを開く',
'assistant.connect.badKey': 'このキーは使えません。',
'assistant.connect.unknownModel': 'このモデルはここでは提供されていません。',
'assistant.connect.noModelList':
'サーバーは応答しましたが、モデル一覧が返りませんでした。アドレスを確認してください。通常は /v1 で終わります。',
'assistant.connect.getKey': 'キーを取得',
'assistant.connect.hintAnthropic': 'Claude Max と Team には API クレジットが含まれています。',
'assistant.connect.hintOpenai': 'OpenAI のダッシュボードでキーを作成してください。',
+1 -1
View File
@@ -290,7 +290,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': '自動レイアウト',
'journey.studio.export': 'エクスポート',
'journey.studio.day': '{day}日目',
'journey.studio.stations': '立ち寄り先',
'journey.studio.stations': '道中で',
'journey.studio.peersHere': '閲覧中',
'journey.studio.folioAuto': '自動',
'journey.studio.exportLayout': 'レイアウト',
+1
View File
@@ -930,6 +930,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': '{name}にKMLファイルが見つかりません',
'tours.import.archiveTooLargeIn': '{name}は展開すると大きすぎます',
'tours.import.previewDuplicate': 'すでに旅行にあります',
'tours.import.previewAdopts': '旅行にトラックとしてあります。ツアーになります',
'tours.import.previewAllDuplicates': 'すべてのトラックがすでに旅行にあるため、取り込むものはありません。',
'tours.import.previewAs': '取り込む種類',
'tours.import.previewConfirm': '{count} 件のツアーを取り込む',
+2
View File
@@ -41,6 +41,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': '어시스턴트 열기',
'assistant.connect.badKey': '이 키는 작동하지 않습니다.',
'assistant.connect.unknownModel': '이 모델은 여기에서 제공되지 않습니다.',
'assistant.connect.noModelList':
'서버가 응답했지만 모델 목록을 보내지 않았습니다. 주소를 확인하세요. 보통 /v1로 끝납니다.',
'assistant.connect.getKey': '키 받기',
'assistant.connect.hintAnthropic': 'Claude Max와 Team에는 API 크레딧이 포함되어 있습니다.',
'assistant.connect.hintOpenai': 'OpenAI 대시보드에서 키를 만드세요.',
+1 -1
View File
@@ -287,7 +287,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': '자동 레이아웃',
'journey.studio.export': '내보내기',
'journey.studio.day': '{day}일차',
'journey.studio.stations': '경유지',
'journey.studio.stations': '가는 길에',
'journey.studio.peersHere': '접속 중',
'journey.studio.folioAuto': '자동',
'journey.studio.exportLayout': '레이아웃',
+1
View File
@@ -922,6 +922,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': '{name}에 KML 파일이 없습니다',
'tours.import.archiveTooLargeIn': '{name}은(는) 압축을 풀면 너무 큽니다',
'tours.import.previewDuplicate': '이미 여행에 있음',
'tours.import.previewAdopts': '이미 여행에 트랙으로 있음, 투어가 됩니다',
'tours.import.previewAllDuplicates': '모든 트랙이 이미 여행에 있어 가져올 것이 없습니다.',
'tours.import.previewAs': '가져올 유형',
'tours.import.previewConfirm': '투어 {count}개 가져오기',
+2
View File
@@ -41,6 +41,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Assistent openen',
'assistant.connect.badKey': 'Deze sleutel werkt niet.',
'assistant.connect.unknownModel': 'Dit model wordt hier niet aangeboden.',
'assistant.connect.noModelList':
'De server antwoordde, maar stuurde geen lijst met modellen. Controleer het adres, dat eindigt meestal op /v1.',
'assistant.connect.getKey': 'Sleutel aanvragen',
'assistant.connect.hintAnthropic': 'Claude Max en Team bevatten API-tegoed.',
'assistant.connect.hintOpenai': 'Maak een sleutel aan in je OpenAI-dashboard.',
+1 -1
View File
@@ -300,7 +300,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Automatische opmaak',
'journey.studio.export': 'Exporteren',
'journey.studio.day': 'DAG {day}',
'journey.studio.stations': 'Etappes',
'journey.studio.stations': 'Onderweg',
'journey.studio.peersHere': 'hier',
'journey.studio.folioAuto': 'Automatisch',
'journey.studio.exportLayout': 'Opmaak',
+1
View File
@@ -967,6 +967,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Geen KML-bestand gevonden in {name}',
'tours.import.archiveTooLargeIn': '{name} is uitgepakt te groot',
'tours.import.previewDuplicate': 'Al in de reis',
'tours.import.previewAdopts': 'Al een track in de reis, wordt een tocht',
'tours.import.previewAllDuplicates': 'Alle tracks staan al in de reis, er is niets te importeren.',
'tours.import.previewAs': 'Importeren als',
'tours.import.previewConfirm': '{count} tochten importeren',
+2
View File
@@ -41,6 +41,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Otwórz asystenta',
'assistant.connect.badKey': 'Ten klucz nie działa.',
'assistant.connect.unknownModel': 'Ten model nie jest tu dostępny.',
'assistant.connect.noModelList':
'Serwer odpowiedział, ale nie przesłał listy modeli. Sprawdź adres, zwykle kończy się na /v1.',
'assistant.connect.getKey': 'Uzyskaj klucz',
'assistant.connect.hintAnthropic': 'Claude Max i Team zawierają środki na API.',
'assistant.connect.hintOpenai': 'Utwórz klucz w panelu OpenAI.',
+1 -1
View File
@@ -312,7 +312,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Układ automatyczny',
'journey.studio.export': 'Eksportuj',
'journey.studio.day': 'DZIEŃ {day}',
'journey.studio.stations': 'Etapy',
'journey.studio.stations': 'Po drodze',
'journey.studio.peersHere': 'tutaj',
'journey.studio.folioAuto': 'Automatycznie',
'journey.studio.exportLayout': 'Układ',
+1
View File
@@ -986,6 +986,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'W pliku {name} nie ma pliku KML',
'tours.import.archiveTooLargeIn': 'Plik {name} po rozpakowaniu jest za duży',
'tours.import.previewDuplicate': 'Już jest w podróży',
'tours.import.previewAdopts': 'Już jest śladem w podróży, stanie się wycieczką',
'tours.import.previewAllDuplicates': 'Wszystkie ślady są już w podróży, nie ma czego importować.',
'tours.import.previewAs': 'Importuj jako',
'tours.import.previewConfirm': 'Importuj {count} wycieczki',
+2
View File
@@ -41,6 +41,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Открыть ассистента',
'assistant.connect.badKey': 'Этот ключ не работает.',
'assistant.connect.unknownModel': 'Эта модель здесь недоступна.',
'assistant.connect.noModelList':
'Сервер ответил, но не прислал список моделей. Проверьте адрес, обычно он заканчивается на /v1.',
'assistant.connect.getKey': 'Получить ключ',
'assistant.connect.hintAnthropic': 'В Claude Max и Team входят кредиты API.',
'assistant.connect.hintOpenai': 'Создайте ключ в панели управления OpenAI.',
+1 -1
View File
@@ -312,7 +312,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Автомакет',
'journey.studio.export': 'Экспорт',
'journey.studio.day': 'ДЕНЬ {day}',
'journey.studio.stations': 'Остановки',
'journey.studio.stations': 'По пути',
'journey.studio.peersHere': 'здесь',
'journey.studio.folioAuto': 'Автоматически',
'journey.studio.exportLayout': 'Макет',
+1
View File
@@ -989,6 +989,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'В {name} не найден файл KML',
'tours.import.archiveTooLargeIn': '{name} слишком велик после распаковки',
'tours.import.previewDuplicate': 'Уже в поездке',
'tours.import.previewAdopts': 'Уже есть в поездке как трек, станет туром',
'tours.import.previewAllDuplicates': 'Все треки уже в поездке, импортировать нечего.',
'tours.import.previewAs': 'Импортировать как',
'tours.import.previewConfirm': 'Импортировать {count} тура',
+2
View File
@@ -40,6 +40,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Otvoriť asistenta',
'assistant.connect.badKey': 'Tento kľúč nefunguje.',
'assistant.connect.unknownModel': 'Tento model tu nie je k dispozícii.',
'assistant.connect.noModelList':
'Server odpovedal, ale neposlal zoznam modelov. Skontrolujte adresu, zvyčajne končí na /v1.',
'assistant.connect.getKey': 'Získať kľúč',
'assistant.connect.hintAnthropic': 'Claude Max a Team obsahujú kredity pre API.',
'assistant.connect.hintOpenai': 'Vytvor kľúč vo svojom prehľade OpenAI.',
+1 -1
View File
@@ -304,7 +304,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Automatické rozloženie',
'journey.studio.export': 'Exportovať',
'journey.studio.day': 'DEŇ {day}',
'journey.studio.stations': 'Zastávky',
'journey.studio.stations': 'Cestou',
'journey.studio.peersHere': 'tu',
'journey.studio.folioAuto': 'Automaticky',
'journey.studio.exportLayout': 'Rozloženie',
+1
View File
@@ -954,6 +954,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'V súbore {name} nie je žiadny súbor KML',
'tours.import.archiveTooLargeIn': 'Súbor {name} je po rozbalení príliš veľký',
'tours.import.previewDuplicate': 'Už je v ceste',
'tours.import.previewAdopts': 'Už je v ceste ako trasa, stane sa túrou',
'tours.import.previewAllDuplicates': 'Všetky trasy už v ceste sú, nie je čo importovať.',
'tours.import.previewAs': 'Importovať ako',
'tours.import.previewConfirm': 'Importovať {count} túr',
+2
View File
@@ -42,6 +42,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'Öppna assistenten',
'assistant.connect.badKey': 'Den här nyckeln fungerar inte.',
'assistant.connect.unknownModel': 'Den här modellen erbjuds inte här.',
'assistant.connect.noModelList':
'Servern svarade men skickade ingen lista med modeller. Kontrollera adressen, den slutar oftast på /v1.',
'assistant.connect.getKey': 'Skaffa en nyckel',
'assistant.connect.hintAnthropic': 'Claude Max och Team inkluderar API-krediter.',
'assistant.connect.hintOpenai': 'Skapa en nyckel i din OpenAI-översikt.',
+1 -1
View File
@@ -298,7 +298,7 @@ const journey: TranslationStrings = {
'journey.studio.autoLayout': 'Automatisk layout',
'journey.studio.export': 'Exportera',
'journey.studio.day': 'DAG {day}',
'journey.studio.stations': 'Etapper',
'journey.studio.stations': 'Längs vägen',
'journey.studio.peersHere': 'här',
'journey.studio.folioAuto': 'Automatiskt',
'journey.studio.exportLayout': 'Layout', // same-as-en
+1
View File
@@ -945,6 +945,7 @@ const tours: TranslationStrings = {
'tours.import.archiveNoKmlIn': 'Ingen KML-fil hittades i {name}',
'tours.import.archiveTooLargeIn': '{name} är för stor när den packats upp',
'tours.import.previewDuplicate': 'Finns redan i resan',
'tours.import.previewAdopts': 'Finns redan som spår i resan, blir en tur',
'tours.import.previewAllDuplicates': 'Alla spår finns redan i resan, det finns inget att importera.',
'tours.import.previewAs': 'Importera som',
'tours.import.previewConfirm': 'Importera {count} turer',
+2
View File
@@ -40,6 +40,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'เปิดผู้ช่วย',
'assistant.connect.badKey': 'คีย์นี้ใช้งานไม่ได้',
'assistant.connect.unknownModel': 'ไม่มีโมเดลนี้ให้ใช้ที่นี่',
'assistant.connect.noModelList':
'เซิร์ฟเวอร์ตอบกลับแล้ว แต่ไม่ได้ส่งรายการโมเดลมา ตรวจสอบที่อยู่ ซึ่งมักลงท้ายด้วย /v1',
'assistant.connect.getKey': 'รับคีย์',
'assistant.connect.hintAnthropic': 'Claude Max และ Team มีเครดิต API รวมอยู่ด้วย',
'assistant.connect.hintOpenai': 'สร้างคีย์ในแดชบอร์ด OpenAI ของคุณ',

Some files were not shown because too many files have changed in this diff Show More