Compare commits

...

37 Commits

Author SHA1 Message Date
Maurice aee275e2b0 fix(places): read an imported row for the dedup set in one helper
The candidate a GPX or KML row is checked as is built in lineCandidate, so
places.service.ts stays within its size baseline, and the atomic import test
imports the same Ridge line it later expects to be skipped.
2026-10-28 15:43:52 +01:00
Maurice ef86bf2660 fix(places): keep two different tracks of the same name apart on import
Two GPX tracks a watch named alike (two "Afternoon Hike"s) were taken for one:
the import compared a track only by its name, so the second was skipped as
already in the trip. A track now matches a track of that name only when it
also starts, ends and reaches as far as that one, to about a hundred metres,
so the same file imported again is still skipped. The GPX, KML and tour
imports and the tour preview all read the same rule.

Closes #2870
2026-10-28 15:43:52 +01:00
Maurice 0139c46fb4 test(i18n): count the new planned total key in the tours locales 2026-10-28 15:18:07 +01:00
Maurice a0a7fcdbb9 fix(tours): say why a tour over a day cannot be saved
A tour whose walking time and breaks came to more than 24 hours a stage
only greyed out Save, with nothing saying why. The planner now marks the
planned total and says what to do: make it a multi-day tour by ending a
stage at a stop, or enter a shorter planned total. The details section
opens by itself so the reason is in sight.

Closes #2869
2026-10-28 14:52:01 +01:00
Maurice 186dd94b61 fix(assistant): chat with an own server entered without /v1 at /v1
The assistant sent an own server's chat to the address as entered plus
/chat/completions. Ollama, entered as http://host:11434 the way its own docs
write it, answers that with a 404, so every message ended in "something went
wrong" while connecting had worked: the model list is found under the root
at /api/tags. A bare server address now chats under /v1, where Ollama, LM
Studio, vLLM, llama.cpp and LiteLLM all answer; an address with a path, such
as https://api.groq.com/openai/v1, is used as entered.
2026-10-28 12:17:54 +01:00
Maurice ad721a1ac6 test(studio): the auto layout lays out a long journey whole 2026-10-28 12:26:06 +01:00
Maurice 87441f269d fix(studio): lay out a book as long as its journey, with no page limit
A book held at most 150 spreads. The auto layout cut everything past that
without a word, so a long journey's book ended about three quarters of the
way through, with its last page and back cover gone, and adding, copying or
applying a designed page to a full book was refused.

The spread cap is gone from the book schema, the page operations, the
design composer and its apply, the assistant's book card and the design tool
contracts. A design's own page budget (pages.max) still bounds what it lays
out. The save route's 8 MB body still bounds a book's size, which at two to
five kilobytes an auto laid page is a few thousand pages.

The templated design digests move because their counter ids shift by two;
the pages, their order and their elements are unchanged.
2026-10-28 12:06:50 +01:00
github-actions[bot] a0943f8226 chore: bump version to 5.0.1 [skip ci] 2026-10-28 10:43:52 +00:00
Maurice 6741e8ca0d fix(help): give the close button a row of its own
A wider gutter left the button floating in the corner over the first card and the scrollbar. It now sits in its own row above the scrolling content, level with the Help title, and the content keeps symmetric padding.
2026-10-28 10:24:08 +01:00
Maurice c14f179e39 fix(help): keep the help center's content clear of its close button
The close button floats over the scrolling content, which only kept a 40px gutter on the end side while the button reaches 52px in, so the first card ran right up to it. The content now keeps a wider gutter on that side.
2026-10-28 10:24:08 +01:00
Maurice 3bfef73a46 refactor(maps): move the place clusters out of MapViewGL
The cluster source, its layers, the expansion zoom and the DOM bubbles
now live in placeClusterLayers.ts with their own tests, which brings
MapViewGL back under its size baseline instead of growing it. Lowers the
size and eslint baselines that this change made smaller.
2026-10-28 10:11:56 +01:00
Maurice 7e790c3fb7 fix(maps): draw the GL cluster bubbles like the Leaflet ones
The MapLibre and Mapbox maps painted their place clusters as a canvas
circle plus a symbol layer, so the count used whatever glyphs the map
style ships and the bubble had no hover. They are now DOM pins built by
the same markup and CSS as the Leaflet bubbles, with the same sizes,
font and hover scale. The canvas layers stay, invisible, as the hit
area the track and route layers check a click against.
2026-10-28 10:11:56 +01:00
Maurice 228db8541c chore(notices): show 5.0.1 in the release popup
The release popup still named 5.0.0 and linked the 5.0.0 release notes. It now names 5.0.1 and links its tag.
2026-10-28 09:52:18 +01:00
Maurice a729a6935f fix(journey): keep the photo picker's calendar inside the screen
The date picker of the external photo picker hung absolutely under its
trigger, inside a dialog that clips its content. The last week of a
six-week month fell below the dialog, and for the end date in the
right-hand column the calendar opened past the dialog's right edge, so
the last weekday and the next-month arrow could not be reached.

The calendar now opens in a portal, pinned to its trigger through
useAnchoredPosition: it flips above the trigger when below is too short,
scrolls when neither side holds it, and is kept inside the viewport
sideways.

Closes #2858
2026-10-28 09:25:53 +01:00
Maurice 6c0e39e287 fix(transit): name and record the backend that answered, not always Transitous
With Google as the transit provider, the result list still credited
Transitous, and a route added from the panel or through
create_transit_journey was saved with provider 'transitous'. The panel
now credits "Google Maps" (untranslated, as Google's terms ask) or
Transitous after the backend that answered and saves that one; the MCP
journey records the backend that plans routes for the caller, and
search_transit_routes reports it.
2026-10-27 21:30:18 +01:00
Maurice 22574a2270 fix(assistant): send Gemini's thought signatures back with its tool calls
Gemini 3 signs a tool call on its OpenAI-compatible endpoint
(extra_content.google.thought_signature) and refuses the next request
with a 400 when the call comes back without it, so every turn in which
the model called a tool failed. The Chat Completions client now keeps
the signature per call as the step's provider state and sends it back
with the call, to the model that signed it only.

An own server entered with Gemini's endpoint now also gets Gemini's
narrower tool schemas, which it refused before.
2026-10-27 21:06:29 +01:00
Maurice 1d880cb881 style(tours): format the route file reader 2026-10-27 15:29:00 +01:00
Maurice 3d076704f9 test: keep the new day-note and document-sync tests inside the lint and type gates 2026-10-27 15:16:50 +01:00
Maurice 1b0f043506 fix(roadtrip): stop pacing the joins between days that come back from the route cache
With connected days, every join between two days kept its one-request-a-second
slot even when the route came back from the device's cache, so a long road
trip still waited about a second per join on every open. A join answered from
the cache now gives its slot back; requests that go out stay spaced as before.

Refs #2794
2026-10-27 15:02:24 +01:00
Maurice c383b11648 fix(assistant): propose a route file's track from before Tours instead of skipping it
The Tours import takes over a same-named track a trip kept from 4.x, but a
route file attached in the assistant chat was read with the plain duplicate
rule only, so that track was reported as already in the trip and never
proposed. The read now leaves such a track to the import, which takes it
over as a tour like the Tours dialog does.

Refs #2810
2026-10-27 15:02:24 +01:00
Maurice fed564e7c0 fix(assistant): answer the model's own calls of server tools when a thread is replayed
remember_for_trip runs at once during a turn, and read_attached_file and
keep_attached_file are answered by the server, but all three are calls the
model made. Rebuilding a thread from its stored steps left them unanswered,
and ChatGPT, the OpenAI reasoning models and Anthropic replay their own record
of each step, call included, so the provider refused the conversation with a
400: the continuation after applied changes failed, and every later message of
the thread with it. A step that kept a note also sent an empty user message.
Every call the model made is now answered as in the live turn, the attached
file's outcome as the answer of its call; threads that broke this way work
again on their next message. A refusal's log line names the refused field's
path, never its message.

Closes #2845
2026-10-27 14:53:13 +01:00
Maurice 9e216f5ae4 fix(docsync): let the trip owner edit or remove a stored document-store connection
Once a store was connected for a trip, its address and login could only be
entered again after a sync run had been refused, and the stored connection
could not be removed at all: the server's DELETE route had no way in from the
app. A binding's settings now open the connection at any time, the folder
picker leads back to it before a folder is chosen, and the connection form
removes a stored connection after a confirmation, on the desktop panel and the
phone sheet alike. Removing it drops the trip's bindings through it; documents
stay on both sides.

Closes #2842
2026-10-27 14:45:17 +01:00
Maurice 0f0232491b fix(mcp): describe day-note title and body and allow the planner's 2000-character body
create_day_note and update_day_note described `time` as a clock label and capped
it at 250 characters, while the planner shows it as the Markdown body under the
title and allows 2000. The assistant put long text in the title and had to cut
notes it was asked to edit. Both tools now take text and time from the REST
contract with descriptions naming title and body, and an update still refuses an
empty title. The assistant's change card labels the two fields title and content
instead of text and time.

Closes #2840
2026-10-27 14:38:30 +01:00
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
231 changed files with 3206 additions and 737 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 5.0.0
version: 5.0.1
description: Minimal Helm chart for TREK app
appVersion: "5.0.0"
appVersion: "5.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "5.0.0",
"version": "5.0.1",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
+2 -2
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,
@@ -30,7 +30,7 @@
"no-useless-escape": 1
},
"suppressed": {
"@typescript-eslint/no-explicit-any": 40,
"@typescript-eslint/no-explicit-any": 38,
"react-hooks/exhaustive-deps": 46
},
"tests": {
+1 -1
View File
@@ -7,7 +7,7 @@
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/MapViewGL.tsx": 2218,
"src/components/Map/RouteCalculator.ts": 1063,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
@@ -440,6 +440,8 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
currency: { labelKey: 'assistant.change.label.currency' },
stopType: { labelKey: 'assistant.change.label.stopType' },
text: { labelKey: 'assistant.change.label.text' },
title: { labelKey: 'assistant.change.label.title' },
content: { labelKey: 'assistant.change.label.content' },
icon: { labelKey: 'assistant.change.label.icon' },
color: { labelKey: 'assistant.change.label.color' },
position: { labelKey: 'assistant.change.label.position' },
@@ -1,4 +1,4 @@
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-030
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-032
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
@@ -66,6 +66,7 @@ function renderCard(
canManage?: boolean
providerName?: string
onReconnect?: () => void
onEditConnection?: () => void
} = {},
) {
const utils = render(
@@ -75,6 +76,7 @@ function renderCard(
sync={makeSync(opts.sync)}
canManage={opts.canManage ?? true}
onReconnect={opts.onReconnect}
onEditConnection={opts.onEditConnection}
/>,
)
const header = utils.container.querySelector('header') as HTMLElement
@@ -421,3 +423,31 @@ describe('DocSyncBinding: the direction lanes', () => {
expect(updateLink).not.toHaveBeenCalled()
})
})
describe('DocSyncBinding: the connection behind it', () => {
const S = en as unknown as Record<string, string>
const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] })
it('FE-DOCSYNC-BIND-031: the settings open the stored connection at any time, not only after a refusal', () => {
const onEditConnection = vi.fn()
renderCard({ link: { lastSyncState: 'ok' }, onEditConnection })
expect(editButton()).not.toBeInTheDocument()
openSettings()
expect(screen.getByText(S['docsync.connection.title'])).toBeInTheDocument()
fireEvent.click(editButton() as HTMLElement)
expect(onEditConnection).toHaveBeenCalledTimes(1)
})
it('FE-DOCSYNC-BIND-032: a member, or a panel without the form, gets no way to the connection', () => {
const member = renderCard({ canManage: false, onEditConnection: vi.fn() })
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
expect(editButton()).not.toBeInTheDocument()
member.unmount()
renderCard()
openSettings()
expect(editButton()).not.toBeInTheDocument()
})
})
@@ -1,6 +1,6 @@
import { useState } from 'react'
import {
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, Link2Off, RefreshCw,
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, KeyRound, Link2Off, RefreshCw,
} from 'lucide-react'
import ConfirmDialog from '../../shared/ConfirmDialog'
import CustomSelect from '../../shared/CustomSelect'
@@ -27,6 +27,7 @@ export default function DocSyncBinding({
sync,
canManage,
onReconnect,
onEditConnection,
}: {
link: DocSyncLink
providerName: string
@@ -38,6 +39,8 @@ export default function DocSyncBinding({
* not carry; left out, a refused credential is reported but not curable.
*/
onReconnect?: () => void
/** The same form at any time, to change the address or the login, or to remove the connection. */
onEditConnection?: () => void
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@@ -237,6 +240,21 @@ export default function DocSyncBinding({
</div>
</div>
)}
{onEditConnection && (
<Row label={t('docsync.connection.title')} hint={t('docsync.connection.hint')}>
<span className="flex justify-end">
<button
type="button"
onClick={onEditConnection}
className="flex items-center gap-1.5 rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
<KeyRound size={13} />
{t('docsync.connection.edit')}
</button>
</span>
</Row>
)}
</div>
)}
</>
@@ -1,4 +1,4 @@
// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-027
// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-030
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor, act, within } from '../../../../tests/helpers/render'
@@ -13,6 +13,7 @@ const listLinks = vi.fn(async (_tripId: number | string): Promise<unknown> => []
const status = vi.fn(async (_tripId: number | string): Promise<unknown> => ({ items: {}, links: [] }))
const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ id: 1 }))
const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
const deleteConnection = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
@@ -26,6 +27,7 @@ vi.mock('../../../api/client', async (importOriginal) => {
listLinks: (tripId: number | string) => listLinks(tripId),
saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data),
testConnection: (tripId: number | string, data: unknown) => testConnection(tripId, data),
deleteConnection: (tripId: number | string, id: number) => deleteConnection(tripId, id),
},
}
})
@@ -81,11 +83,13 @@ function Harness({
provider,
onClose,
onConnected,
onRemoved,
waitForConnection,
}: {
provider: DocSyncProvider
onClose: () => void
onConnected: (providerId: string) => void
onRemoved?: () => void
/**
* Hold the dialog back until the stored connection is in hand, the way the
* panel does: it only offers "connect" once it knows what is already there.
@@ -94,7 +98,9 @@ function Harness({
}) {
const sync = useDocSync(TRIP_ID, true)
if (waitForConnection && !sync.connectionFor(provider.id)) return null
return <DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} />
return (
<DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} onRemoved={onRemoved} />
)
}
/** Let the hook's initial load land before anything is asserted. */
@@ -492,3 +498,52 @@ describe('DocSyncConnectModal with a connection already stored', () => {
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ baseUrl: 'https://cloud.example.com' }))
})
})
describe('DocSyncConnectModal removal', () => {
const removeButton = () => screen.queryByRole('button', { name: tx('docsync.connection.remove') })
// The confirmation is portalled after the dialog, so its button is the last one of that name.
const confirmRemoval = () => {
const named = screen.getAllByRole('button', { name: tx('docsync.connection.remove') })
return named[named.length - 1]
}
async function openWithRemoval(stored: boolean) {
if (stored) listConnections.mockResolvedValue([connection()])
const onRemoved = vi.fn()
render(<Harness provider={nextcloud} onClose={vi.fn()} onConnected={vi.fn()} onRemoved={onRemoved} waitForConnection={stored} />)
await flush()
return onRemoved
}
it('FE-DOCSYNC-CONNECT-028: a stored connection can be removed after a confirmation, and the dialog is told', async () => {
const onRemoved = await openWithRemoval(true)
await waitFor(() => expect(removeButton()).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: tx('docsync.connection.remove') }))
expect(deleteConnection).not.toHaveBeenCalled()
await screen.findByText(tx('docsync.connection.confirmRemove'))
fireEvent.click(confirmRemoval())
await waitFor(() => expect(deleteConnection).toHaveBeenCalledWith(TRIP_ID, 9))
await waitFor(() => expect(onRemoved).toHaveBeenCalled())
})
it('FE-DOCSYNC-CONNECT-029: a refused removal keeps the dialog and says why', async () => {
deleteConnection.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
const onRemoved = await openWithRemoval(true)
await waitFor(() => expect(removeButton()).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: tx('docsync.connection.remove') }))
await screen.findByText(tx('docsync.connection.confirmRemove'))
fireEvent.click(confirmRemoval())
await waitFor(() => expect(deleteConnection).toHaveBeenCalled())
expect(onRemoved).not.toHaveBeenCalled()
expect(await screen.findByText(tx('docsync.error.forbidden'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-030: a first connection has nothing to remove', async () => {
await openWithRemoval(false)
expect(removeButton()).not.toBeInTheDocument()
})
})
@@ -1,6 +1,7 @@
import { useId, useState } from 'react'
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
import ConfirmDialog from '../../shared/ConfirmDialog'
import { Tooltip } from '../../shared/Tooltip'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useTranslation } from '../../../i18n/TranslationContext'
@@ -22,11 +23,14 @@ export default function DocSyncConnectModal({
sync,
onClose,
onConnected,
onRemoved,
}: {
provider: DocSyncProvider
sync: ReturnType<typeof useDocSync>
onClose: () => void
onConnected: (providerId: string) => void
/** After the stored connection was removed from here; left out, the form offers no removal. */
onRemoved?: () => void
}) {
const { t } = useTranslation()
const existing = sync.connectionFor(provider.id)
@@ -34,6 +38,11 @@ export default function DocSyncConnectModal({
const form = useConnectForm(provider, existing, sync)
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
const [confirmRemove, setConfirmRemove] = useState(false)
const remove = async () => {
if (existing && (await sync.removeConnection(existing.id))) onRemoved?.()
}
const test = async () => setVerdict(await form.probe())
const labelId = useId()
@@ -46,6 +55,7 @@ export default function DocSyncConnectModal({
<DialogShell
onClose={onClose}
labelledBy={labelId}
blocked={confirmRemove}
header={(
<DialogHeader
tile={<DialogTile>{Icon ? <Icon className="h-5 w-5 text-content" /> : <Plug size={20} strokeWidth={1.9} className="text-content-muted" />}</DialogTile>}
@@ -57,6 +67,13 @@ export default function DocSyncConnectModal({
)}
footer={(
<DialogFooter>
{existing && onRemoved && (
<DeleteButton
label={t('docsync.connection.remove')}
onClick={() => setConfirmRemove(true)}
disabled={sync.busy === 'connection'}
/>
)}
<Verdict verdict={verdict} busy={sync.busy === 'test'} failure={sync.error} />
<FooterSpacer />
<DialogButton onClick={() => void test()} disabled={!form.complete || sync.busy === 'test'}>
@@ -101,6 +118,15 @@ export default function DocSyncConnectModal({
</span>
</label>
</div>
<ConfirmDialog
isOpen={confirmRemove}
onClose={() => setConfirmRemove(false)}
onConfirm={() => void remove()}
title={t('docsync.connection.remove')}
message={t('docsync.connection.confirmRemove')}
confirmLabel={t('docsync.connection.remove')}
/>
</DialogShell>
)
}
@@ -62,6 +62,9 @@ export default function DocSyncPanel({
setSelected(prev => (sync.links.some(l => l.id === prev) ? prev : (sync.links[0]?.id ?? null)))
}, [sync.links])
// The connection behind the folder picker, which the picker can send back to.
const scopeProvider = scopeFor ? sync.providers.find(p => p.id === scopeFor) : undefined
const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0)
const labelId = useId()
@@ -113,6 +116,7 @@ export default function DocSyncPanel({
sync={sync}
canManage={canManage}
onReconnect={activeProvider ? () => setReconnecting(activeProvider) : undefined}
onEditConnection={canManage && activeProvider ? () => setReconnecting(activeProvider) : undefined}
/>
{/* Every action in this column reports here: a refused run, a
switch that flipped back, a conflict the server would not
@@ -141,6 +145,7 @@ export default function DocSyncPanel({
sync={sync}
onClose={() => setConnecting(null)}
onConnected={id => { setConnecting(null); setScopeFor(id) }}
onRemoved={() => setConnecting(null)}
/>
)}
@@ -149,6 +154,7 @@ export default function DocSyncPanel({
provider={reconnecting}
sync={sync}
onClose={() => setReconnecting(null)}
onRemoved={() => setReconnecting(null)}
onConnected={id => {
setReconnecting(null)
// Run straight away, as binding does: the card still reports the
@@ -168,6 +174,7 @@ export default function DocSyncPanel({
sync={sync}
onClose={() => setScopeFor(null)}
onBound={() => setScopeFor(null)}
onEditConnection={scopeProvider ? () => { setScopeFor(null); setConnecting(scopeProvider) } : undefined}
/>
)}
</>
@@ -1,4 +1,4 @@
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-023
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-024
/**
* The folder picker of the document-sync dialog.
@@ -84,9 +84,10 @@ function deferred<T>() {
return { promise, resolve, reject }
}
function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }: {
function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless', onEditConnection }: {
suggestedName?: string
providerName?: string
onEditConnection?: () => void
}) {
const sync = useDocSync(TRIP_ID, false)
return (
@@ -97,6 +98,7 @@ function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }
sync={sync}
onClose={onClose}
onBound={onBound}
onEditConnection={onEditConnection}
/>
)
}
@@ -381,3 +383,20 @@ describe('DocSyncScopeModal errors', () => {
expect(createButton()).toBeEnabled()
})
})
describe('DocSyncScopeModal connection', () => {
it('FE-DOCSYNC-SCOPE-024: leads back to the stored connection before a folder is chosen', async () => {
const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] })
const onEditConnection = vi.fn()
const first = render(<Harness onEditConnection={onEditConnection} />)
await waitFor(() => expect(listScopes).toHaveBeenCalled())
fireEvent.click(editButton() as HTMLElement)
expect(onEditConnection).toHaveBeenCalledTimes(1)
expect(createLink).not.toHaveBeenCalled()
first.unmount()
render(<Harness />)
expect(editButton()).not.toBeInTheDocument()
})
})
@@ -1,5 +1,5 @@
import { useId, useMemo, useState } from 'react'
import { FolderPlus, Loader2, Search } from 'lucide-react'
import { FolderPlus, KeyRound, Loader2, Search } from 'lucide-react'
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell'
import { useTranslation } from '../../../i18n/TranslationContext'
import type { DocSyncConnection, useDocSync } from './useDocSync'
@@ -22,6 +22,7 @@ export default function DocSyncScopeModal({
sync,
onClose,
onBound,
onEditConnection,
}: {
connection: DocSyncConnection
providerName: string
@@ -30,6 +31,8 @@ export default function DocSyncScopeModal({
sync: ReturnType<typeof useDocSync>
onClose: () => void
onBound: () => void
/** Back to the stored connection, to change the login or remove it before choosing a folder. */
onEditConnection?: () => void
}) {
const { t } = useTranslation()
const labelId = useId()
@@ -63,7 +66,19 @@ export default function DocSyncScopeModal({
)}
>
<div className="space-y-4">
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
<div className="flex items-start justify-between gap-3">
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
{onEditConnection && (
<button
type="button"
onClick={onEditConnection}
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-edge px-2.5 py-1 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
<KeyRound size={13} />
{t('docsync.connection.edit')}
</button>
)}
</div>
<div className="rounded-xl border border-edge bg-surface-secondary p-3">
<span className="mb-2 block text-body font-medium text-content">{t('docsync.scope.createTitle')}</span>
@@ -1,4 +1,4 @@
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-030
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-032
/**
* The document-sync hook.
@@ -22,6 +22,7 @@ const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown)
const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise<unknown> => ({}))
const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({}))
const listScopesApi = vi.fn(async (_tripId: number | string, _connectionId: number, _q?: string): Promise<unknown> => ({ scopes: [] }))
const deleteConnectionApi = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
const resolveApi = vi.fn(async (_tripId: number | string, _itemId: number, _keep: string): Promise<unknown> => ({ success: true }))
vi.mock('../../../api/client', async (importOriginal) => {
@@ -40,6 +41,7 @@ vi.mock('../../../api/client', async (importOriginal) => {
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full),
listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopesApi(tripId, connectionId, q),
resolve: (tripId: number | string, itemId: number, keep: string) => resolveApi(tripId, itemId, keep),
deleteConnection: (tripId: number | string, id: number) => deleteConnectionApi(tripId, id),
},
}
})
@@ -126,6 +128,7 @@ beforeEach(() => {
syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
listScopesApi.mockResolvedValue({ scopes: [] })
resolveApi.mockResolvedValue({ success: true })
deleteConnectionApi.mockResolvedValue({ success: true })
})
describe('useDocSync initial load', () => {
@@ -471,6 +474,38 @@ describe('useDocSync resolveConflict', () => {
})
})
describe('useDocSync removeConnection', () => {
it('FE-DOCSYNC-HOOK-031: forgets the connection and reloads, so its bindings leave the screen too', async () => {
const { result } = await mountLoaded()
expect(result.current.links).toHaveLength(1)
// The server drops the bindings with the connection (ON DELETE CASCADE).
listConnectionsApi.mockResolvedValue([])
listLinksApi.mockResolvedValue([])
let ok: boolean | undefined
await act(async () => { ok = await result.current.removeConnection(9) })
expect(ok).toBe(true)
expect(deleteConnectionApi).toHaveBeenCalledWith(TRIP, 9)
expect(result.current.connectionFor('nextcloud')).toBeNull()
expect(result.current.links).toEqual([])
expect(result.current.busy).toBeNull()
})
it('FE-DOCSYNC-HOOK-032: a refused removal keeps everything and says why', async () => {
deleteConnectionApi.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
const { result } = await mountLoaded()
let ok: boolean | undefined
await act(async () => { ok = await result.current.removeConnection(9) })
expect(ok).toBe(false)
expect(result.current.error).toBe('forbidden')
expect(result.current.connectionFor('nextcloud')).not.toBeNull()
expect(result.current.busy).toBeNull()
})
})
describe('needsReauth', () => {
it('FE-DOCSYNC-HOOK-030: reads a refused credential off either the state or the error code', () => {
expect(needsReauth({ lastSyncState: 'needs_reauth', lastSyncError: null })).toBe(true)
@@ -264,6 +264,27 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
}
}, [tripId, load])
/**
* Forget a stored connection, and with it every binding of this trip that
* runs through it. The documents stay on both sides; what goes is the
* address and the login TREK kept for the store, which is the one thing a
* person changing accounts or folders had no way to take back.
*/
const removeConnection = useCallback(async (connectionId: number) => {
setBusy('connection')
setError(null)
try {
await docsyncApi.deleteConnection(tripId, connectionId)
await load()
return true
} catch (e: unknown) {
setError(readError(e))
return false
} finally {
setBusy(null)
}
}, [tripId, load])
/**
* Probe without saving. Always resolves to a verdict object: the route
* answers 200 even for an unreachable instance, because a form showing a typo
@@ -427,7 +448,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
connections, links, itemCounts,
loading, busy, error,
connectionFor, load,
saveConnection, testConnection,
saveConnection, testConnection, removeConnection,
loadScopes, createScope,
createLink, updateLink, removeLink, syncNow, resolveConflict,
}
+13 -9
View File
@@ -102,16 +102,20 @@ export default function HelpPanel(): React.ReactElement | null {
<HelpNav context={context} currentId={contextId} activeGuideId={!searching && guide ? guide.id : null} />
<div className="relative flex flex-col min-h-0 bg-surface" role="main">
<button
type="button"
onClick={closeHelp}
aria-label={t('help.center.close')}
className="absolute top-4 end-4 z-10 w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
>
<X className="w-4 h-4" />
</button>
{/* The close button sits in a row of its own above the scrolling content rather
than floating over it, so no card ever runs up against it. */}
<div className="flex shrink-0 justify-end px-4 pt-4">
<button
type="button"
onClick={closeHelp}
aria-label={t('help.center.close')}
className="w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div ref={scrollRef} className="trek-help-scroll flex-1 min-h-0 overflow-y-auto overscroll-contain">
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 py-8">
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 pt-4 pb-8">
{searching ? (
<HelpSearchResults key="search" />
) : guide ? (
@@ -1,4 +1,4 @@
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-007
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-008
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
@@ -119,3 +119,28 @@ describe('DatePicker week start (#2029)', () => {
}
})
})
describe('DatePicker inside a clipping dialog (#2858)', () => {
it('FE-COMP-JOURNEYDATEPICKER-008: the calendar floats over the page, stays inside the viewport and closes on a click away', async () => {
const user = userEvent.setup()
const { container } = render(
<div style={{ overflow: 'hidden' }}>
<DatePicker value="2026-03-15" onChange={vi.fn()} />
</div>,
)
const trigger = screen.getByText(/Mar 15, 2026/).closest('button') as HTMLButtonElement
// A trigger at the right edge, where the end date of a range sits.
trigger.getBoundingClientRect = () => ({ top: 100, bottom: 136, left: window.innerWidth - 120, right: window.innerWidth - 8, width: 112, height: 36, x: 0, y: 0, toJSON: () => ({}) })
await user.click(trigger)
const day31 = screen.getByRole('button', { name: '31' })
// Rendered outside the dialog that would clip it.
expect(container.contains(day31)).toBe(false)
const panel = day31.closest('.fixed') as HTMLElement
expect(panel.style.top).toBe('140px')
expect(parseFloat(panel.style.left)).toBe(window.innerWidth - 280 - 8)
await user.click(document.querySelector('[role="presentation"]') as HTMLElement)
expect(screen.queryByRole('button', { name: '31' })).toBeNull()
})
})
@@ -1,9 +1,11 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { localIsoDate } from '../../utils/localDate'
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useWeekStartDay } from '../../hooks/useWeekStartDay'
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
/**
* The header steps up a level on each click, days → months → years, and a
@@ -14,6 +16,9 @@ import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
*/
type CalendarView = 'days' | 'months' | 'years'
const YEAR_PAGE_SIZE = 12
/** The calendar's width; `w-[280px]` below. */
const PANEL_WIDTH = 280
const VIEWPORT_PAD = 8
export function DatePicker({ value, onChange, tripDates }: {
value: string
@@ -23,6 +28,13 @@ export function DatePicker({ value, onChange, tripDates }: {
const { t, locale } = useTranslation()
const weekStart = useWeekStartDay()
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
// The calendar used to hang absolutely under its trigger, inside dialogs that clip
// their content: the photo picker cut off the last week and, for the end date in
// the right-hand column, the last weekday (#2858). It now floats over the page,
// pinned to the trigger, flipped above it when below is too short and kept inside
// the viewport sideways.
const anchored = useAnchoredPosition(triggerRef, open, { estimatedHeight: 340, matchWidth: false })
const [view, setView] = useState<CalendarView>('days')
const [yearPageStart, setYearPageStart] = useState(0)
const [viewMonth, setViewMonth] = useState(() => {
@@ -107,6 +119,7 @@ export function DatePicker({ value, onChange, tripDates }: {
return (
<div className="relative">
<button
ref={triggerRef}
type="button"
onClick={toggleOpen}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-start flex items-center justify-between"
@@ -122,12 +135,20 @@ export function DatePicker({ value, onChange, tripDates }: {
<Calendar size={13} className="text-zinc-400" />
</button>
{open && (
{open && anchored && createPortal(
<>
{/* Click-away catcher — no semantics of its own; the trigger button
above closes the popover again from the keyboard. */}
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
<div className="absolute top-full start-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
<div role="presentation" className="fixed inset-0 z-[var(--z-toast)]" onClick={() => setOpen(false)} />
<div
className="fixed z-[var(--z-toast)] overflow-y-auto bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]"
style={{
top: anchored.top,
bottom: anchored.bottom,
left: Math.max(VIEWPORT_PAD, Math.min(anchored.left, window.innerWidth - PANEL_WIDTH - VIEWPORT_PAD)),
maxHeight: anchored.maxHeight,
}}
>
{/* Header: arrows step the current view, the label climbs to the next one up */}
<div className="flex items-center justify-between mb-2">
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
@@ -200,7 +221,7 @@ export function DatePicker({ value, onChange, tripDates }: {
>
{day}
{isTrip && !isSelected && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
<span className="absolute bottom-1 inset-x-0 mx-auto w-1 h-1 rounded-full bg-indigo-500" />
)}
</button>
)
@@ -209,7 +230,8 @@ export function DatePicker({ value, onChange, tripDates }: {
</>
)}
</div>
</>
</>,
document.body,
)}
</div>
)
@@ -375,11 +375,12 @@ describe('ProviderPicker', () => {
await user.click(screen.getByRole('button', { name: 'Search' }))
expect(ranges).toHaveLength(0)
// The calendar opens in a portal, so its days are looked up on the page, not under the trigger.
const [fromPicker, toPicker] = Array.from(baseElement.querySelectorAll('.flex-1 > .relative')) as HTMLElement[]
await user.click(within(fromPicker).getByRole('button'))
await user.click(within(fromPicker).getByRole('button', { name: '3' }))
await user.click(screen.getByRole('button', { name: '3' }))
await user.click(within(toPicker).getByRole('button'))
await user.click(within(toPicker).getByRole('button', { name: '9' }))
await user.click(screen.getByRole('button', { name: '9' }))
await user.click(screen.getByRole('button', { name: 'Search' }))
await waitFor(() => expect(ranges).toHaveLength(1))
+18 -91
View File
@@ -50,16 +50,10 @@ import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
// HTML photo markers *and* cluster them natively, so we feed the place points
// into a clustered GeoJSON source. The cluster bubbles render as GL circles +
// a count label; the individual rich HTML markers are then only drawn for the
// points the source reports as currently unclustered. Grouping is always on,
// matching the Leaflet map's MarkerClusterGroup.
const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
import {
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
addPlaceClusterLayers, drawClusterBubbles, expandCluster,
} from './placeClusterLayers'
type PlaceWithCoords = Place & { lat: number; lng: number }
@@ -112,72 +106,6 @@ interface Props extends MapViewProps {
gl: any
}
/**
* How eagerly place markers merge into a cluster.
*
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
* kept as plain numbers rather than shared constants so the GL bundle does not have to
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
* own, and the stops that share a coordinate are folded together at the pin level
* instead — see the reconcile below.
*/
const CLUSTER_RADIUS = 20
const CLUSTER_MAX_ZOOM = 8
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
* change and the initial build cannot drift apart. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function addPlaceClusterLayers(map: any): void {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: CLUSTER_RADIUS,
clusterMaxZoom: CLUSTER_MAX_ZOOM,
})
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-color': '#111827',
'circle-opacity': 0.97,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
'circle-stroke-width': 2.5,
'circle-stroke-color': 'rgba(255,255,255,0.9)',
},
})
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': 'rgba(17,24,39,0.35)',
'text-halo-width': 1,
},
})
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
})
}
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
// A stop that interrupts the drive gets its own small disc, decided before the photo
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
@@ -612,6 +540,8 @@ export function MapViewGL({
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<Map<number, PlacePin>>(new Map())
/** The count bubbles, one per cluster the source currently reports. */
const clusterPinsRef = useRef<PlacePin[]>([])
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
const pinLayerRef = useRef<HTMLDivElement | null>(null)
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
@@ -642,6 +572,7 @@ export function MapViewGL({
// in the same frame the canvas draws, or the ones left out swim against the ones in.
const repositionPins = useCallback(() => {
markersRef.current.forEach(pin => pin.reposition())
clusterPinsRef.current.forEach(pin => pin.reposition())
poiMarkersRef.current.forEach(pin => pin.reposition())
pluginMarkersRef.current.forEach(pin => pin.reposition())
routeViaMarkersRef.current.forEach(pin => pin.reposition())
@@ -1304,21 +1235,7 @@ export function MapViewGL({
? map.queryRenderedFeatures(e.point, { layers: [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID] })
: []
const feature = features?.[0]
const clusterId = feature?.properties?.cluster_id
const coordinates = feature?.geometry?.coordinates
if (clusterId == null || !Array.isArray(coordinates)) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as any
const easeToZoom = (nextZoom: number) => {
try { map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 }) } catch { /* noop */ }
}
try {
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err: Error | null, nextZoom: number) => {
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom)
})
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom)
else if (maybeZoom && typeof maybeZoom.then === 'function') maybeZoom.then(easeToZoom).catch(() => {})
} catch { /* noop */ }
expandCluster(map, feature?.properties?.cluster_id, feature?.geometry?.coordinates)
}
const setClusterCursor = () => {
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
@@ -1704,8 +1621,17 @@ export function MapViewGL({
source.setData(buildPlaceClusterData(places) as any)
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
let raf: number | null = null
const dropClusterPins = () => {
clusterPinsRef.current.forEach(pin => pin.remove())
clusterPinsRef.current = []
}
const reconcileClusterPins = () => {
dropClusterPins()
clusterPinsRef.current = drawClusterBubbles(map, (el, lng, lat) => attachPin(map, gl, pinLayerRef.current, el, lng, lat))
}
const runReconcile = () => {
raf = null
reconcileClusterPins()
const features = map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['!', ['has', 'point_count']] }) || []
const seen = new Set<number>()
const visiblePlaces: PlaceWithCoords[] = []
@@ -1752,6 +1678,7 @@ export function MapViewGL({
return () => {
if (raf !== null) cancelAnimationFrame(raf)
dropClusterPins()
map.off('moveend', scheduleReconcile)
map.off('zoomend', scheduleReconcile)
map.off('idle', scheduleReconcile)
+2 -1
View File
@@ -1,5 +1,6 @@
import L from 'leaflet'
import { STACK_RADIUS_PX } from './coincidentPlaces'
import { clusterBubbleSize } from './placeClusterLayers'
/** Up to here the map is an overview, where stops merge to keep the world readable. */
export const CLUSTER_UNTIL_ZOOM = 9
@@ -66,7 +67,7 @@ export interface ClusterLike {
/** A count bubble sized by its count, styled by `.marker-cluster-custom` in index.css. */
export function createClusterIcon(cluster: ClusterLike) {
const count = cluster.getChildCount()
const size = count < 10 ? 36 : count < 50 ? 42 : 48
const size = clusterBubbleSize(count)
return L.divIcon({
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
className: 'marker-cluster-wrapper',
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest';
import {
type ClusterMap,
PLACE_CLUSTER_SOURCE_ID,
addPlaceClusterLayers,
clusterBubbleSize,
createClusterBubble,
drawClusterBubbles,
} from './placeClusterLayers';
function fakeMap(features: unknown[], expansionZoom: unknown = 11) {
const source = { getClusterExpansionZoom: vi.fn(() => expansionZoom) };
const map = {
addSource: vi.fn(),
addLayer: vi.fn(),
getSource: vi.fn((id: string) => (id === PLACE_CLUSTER_SOURCE_ID ? source : undefined)),
easeTo: vi.fn(),
querySourceFeatures: vi.fn((_id: string, query: { filter: unknown[] }) =>
query.filter[0] === 'has' ? features : []
),
};
return { map: map as unknown as ClusterMap & typeof map, source };
}
describe('cluster bubbles', () => {
it('FE-COMP-PLACECLUSTER-001: a bubble grows in three steps with its count', () => {
expect([1, 9, 10, 49, 50, 500].map(clusterBubbleSize)).toEqual([36, 36, 42, 42, 48, 48]);
});
it('FE-COMP-PLACECLUSTER-002: the GL bubble is the Leaflet markup inside a bare wrapper the pin can move', () => {
const wrapper = createClusterBubble(38);
expect(wrapper.className).toBe('');
const bubble = wrapper.firstElementChild as HTMLElement;
expect(bubble.className).toBe('marker-cluster-custom');
expect(bubble.style.width).toBe('42px');
expect(bubble.style.height).toBe('42px');
expect(bubble.textContent).toBe('38');
});
it('FE-COMP-PLACECLUSTER-003: the canvas circle and count stay as an invisible hit area', () => {
const { map } = fakeMap([]);
addPlaceClusterLayers(map);
expect(map.addSource).toHaveBeenCalledWith(PLACE_CLUSTER_SOURCE_ID, expect.objectContaining({ cluster: true }));
const layers = map.addLayer.mock.calls.map(([layer]) => layer as { id: string; paint: Record<string, unknown> });
expect(layers.find((l) => l.id === 'trip-place-clusters-circle')?.paint['circle-opacity']).toBe(0);
expect(layers.find((l) => l.id === 'trip-place-clusters-count')?.paint['text-opacity']).toBe(0);
});
it('FE-COMP-PLACECLUSTER-004: one bubble per cluster, placed at its centre, that zooms in on a click', () => {
const cluster = { properties: { cluster_id: 5, point_count: 12 }, geometry: { coordinates: [139.7, 35.6] } };
const { map, source } = fakeMap([
cluster,
cluster, // the same cluster crossing a tile edge
{ properties: { cluster_id: 6 }, geometry: { coordinates: [1, 1] } }, // no count
{ properties: { cluster_id: 7, point_count: 3 } }, // no geometry
]);
const placed: { el: HTMLElement; lng: number; lat: number }[] = [];
const pins = drawClusterBubbles(map, (el, lng, lat) => {
placed.push({ el, lng, lat });
return lng;
});
expect(pins).toEqual([139.7]);
expect(placed[0].el.textContent).toBe('12');
expect(placed[0].lat).toBe(35.6);
const onMapClick = vi.fn();
document.body.appendChild(placed[0].el);
document.body.addEventListener('click', onMapClick);
placed[0].el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
document.body.removeEventListener('click', onMapClick);
placed[0].el.remove();
// The click is the bubble's: the map underneath never sees it.
expect(onMapClick).not.toHaveBeenCalled();
expect(source.getClusterExpansionZoom).toHaveBeenCalledWith(5, expect.any(Function));
expect(map.easeTo).toHaveBeenCalledWith({ center: [139.7, 35.6], zoom: 11, duration: 350 });
});
it('FE-COMP-PLACECLUSTER-005: a source that reports nothing draws nothing', () => {
const { map } = fakeMap([]);
map.querySourceFeatures.mockReturnValue(null as never);
expect(drawClusterBubbles(map, () => 1)).toEqual([]);
});
});
@@ -5,3 +5,169 @@
*/
export const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle';
export const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count';
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
// HTML photo markers *and* cluster them natively, so we feed the place points
// into a clustered GeoJSON source. The source decides what is clustered; the
// bubbles are drawn as DOM (`drawClusterBubbles`) and the individual rich HTML
// markers only for the points the source reports as currently unclustered.
// Grouping is always on, matching the Leaflet map's MarkerClusterGroup.
export const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters';
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit';
/**
* How eagerly place markers merge into a cluster.
*
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
* kept as plain numbers rather than shared constants so the GL bundle does not have to
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
* own, and the stops that share a coordinate are folded together at the pin level
* instead — see the reconcile in MapViewGL.
*/
const CLUSTER_RADIUS = 20;
const CLUSTER_MAX_ZOOM = 8;
/** The part of either SDK's map the cluster code calls. */
export interface ClusterMap {
addSource: (id: string, source: object) => void;
addLayer: (layer: object) => void;
getSource: (id: string) => unknown;
easeTo: (options: { center: unknown[]; zoom: number; duration: number }) => void;
querySourceFeatures: (id: string, options: { filter: unknown[] }) => ClusterFeature[] | null | undefined;
}
interface ClusterFeature {
properties?: { cluster_id?: unknown; point_count?: unknown } | null;
geometry?: { coordinates?: unknown };
}
interface ClusterSource {
getClusterExpansionZoom?: (id: unknown, done: (err: Error | null, zoom: number) => void) => unknown;
}
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
* change and the initial build cannot drift apart. */
export function addPlaceClusterLayers(map: Pick<ClusterMap, 'addSource' | 'addLayer'>): void {
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: CLUSTER_RADIUS,
clusterMaxZoom: CLUSTER_MAX_ZOOM,
});
// Invisible: the bubble the user sees is DOM (createClusterBubble), these two
// layers stay as the hit area the track and route layers check a click against.
map.addLayer({
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-opacity': 0,
'circle-stroke-opacity': 0,
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
},
});
map.addLayer({
id: PLACE_CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-size': 12,
'text-allow-overlap': true,
},
paint: { 'text-opacity': 0 },
});
map.addLayer({
id: PLACE_UNCLUSTERED_LAYER_ID,
type: 'circle',
source: PLACE_CLUSTER_SOURCE_ID,
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 24,
'circle-opacity': 0,
'circle-stroke-opacity': 0,
},
});
}
/** Eases the map in to the zoom a cluster falls apart at. Both SDKs answer that
* question, one through a callback and one through a promise. */
export function expandCluster(
map: Pick<ClusterMap, 'getSource' | 'easeTo'>,
clusterId: unknown,
coordinates: unknown
): void {
if (clusterId == null || !Array.isArray(coordinates)) return;
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as ClusterSource | undefined;
const easeToZoom = (nextZoom: number) => {
try {
map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 });
} catch {
/* noop */
}
};
try {
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err, nextZoom) => {
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom);
});
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom);
else if (maybeZoom instanceof Promise) maybeZoom.then(easeToZoom).catch(() => {});
} catch {
/* noop */
}
}
/**
* Puts one count bubble on the map per cluster the source currently reports, through
* `place` (the caller's pin factory), and returns what `place` handed back. A cluster
* crossing a tile edge comes back once per tile, so bubbles are keyed by cluster id.
*/
export function drawClusterBubbles<T>(map: ClusterMap, place: (el: HTMLElement, lng: number, lat: number) => T): T[] {
const pins: T[] = [];
const seen = new Set<unknown>();
for (const feature of map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['has', 'point_count'] }) ?? []) {
const clusterId = feature?.properties?.cluster_id;
const count = feature?.properties?.point_count;
const coordinates = feature?.geometry?.coordinates;
if (typeof count !== 'number' || !Array.isArray(coordinates) || seen.has(clusterId)) continue;
seen.add(clusterId);
const el = createClusterBubble(count);
el.addEventListener('click', (ev) => {
ev.stopPropagation();
expandCluster(map, clusterId, coordinates);
});
pins.push(place(el, coordinates[0], coordinates[1]));
}
return pins;
}
/** A count bubble's diameter in pixels: both renderers size their bubbles by it. */
export function clusterBubbleSize(count: number): number {
return count < 10 ? 36 : count < 50 ? 42 : 48;
}
/**
* A count bubble as DOM, styled by `.marker-cluster-custom` in index.css.
*
* The GL map draws its bubbles with this element rather than as canvas text, so they
* read in the app's own font and get the same hover the Leaflet map has. A canvas
* symbol layer can only use the glyphs the map style ships and knows no `:hover`.
* The bubble sits in a bare wrapper because the pin positions its element through
* `transform`, which would otherwise override the hover's scale.
*/
export function createClusterBubble(count: number): HTMLDivElement {
const size = clusterBubbleSize(count);
const bubble = document.createElement('div');
bubble.className = 'marker-cluster-custom';
bubble.style.width = `${size}px`;
bubble.style.height = `${size}px`;
const label = document.createElement('span');
label.textContent = String(count);
bubble.appendChild(label);
const wrapper = document.createElement('div');
wrapper.appendChild(bubble);
return wrapper;
}
@@ -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 ? (
@@ -138,6 +138,34 @@ describe('TransitSearchPanel', () => {
expect(payload.metadata.transit.legs[1]).toMatchObject({ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben' })
})
it('FE-PLANNER-TRANSIT-003b: routes Google answered name Google Maps and are saved as Google’s (#2850)', async () => {
const user = userEvent.setup()
const onAdd = vi.fn().mockResolvedValue({})
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: 'google' })
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
await user.click(await screen.findByText(/08:30 – 09:00/))
// Google's terms want the words as written, so the browser must not translate them.
expect(screen.getByText('Google Maps')).toHaveAttribute('translate', 'no')
expect(screen.queryByRole('link', { name: 'Transitous' })).not.toBeInTheDocument()
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
await waitFor(() => expect(onAdd).toHaveBeenCalled())
expect(onAdd.mock.calls[0][0].metadata.transit.provider).toBe('google')
})
it('FE-PLANNER-TRANSIT-003c: routes Transitous answered credit Transitous with its sources link', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: 'transitous' })
render(<TransitSearchPanel {...makeProps()} />)
await pickFromAndTo(user)
await user.click(screen.getByRole('button', { name: /^Search$/ }))
expect(await screen.findByRole('link', { name: 'Transitous' })).toHaveAttribute('href', 'https://transitous.org/sources/')
expect(screen.queryByText('Google Maps')).not.toBeInTheDocument()
})
it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => {
const user = userEvent.setup()
transitApiMock.plan.mockRejectedValueOnce(new Error('boom'))
@@ -604,7 +604,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
notes: null,
metadata: {
transit: {
provider: 'transitous',
provider: provider ?? 'transitous',
duration: it.duration,
transfers: it.transfers,
walk_seconds: it.walkSeconds,
@@ -760,7 +760,10 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
))}
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', textAlign: 'center', marginTop: 2 }}>
{t('transit.attribution')}{' '}
<a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>
{/* Named after the backend that answered; Google's terms want "Google Maps" as written, never translated. */}
{provider === 'google'
? <span translate="no">Google Maps</span>
: <a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>}
</div>
</div>
)}
@@ -803,6 +803,34 @@ describe('useRoadtripRoutes', () => {
}
})
it('FE-ROADTRIP-ROUTES-052: a join this device routed before is not paced like a request (#2794)', async () => {
// A join kept its request slot whatever answered it, so a month of connected days
// waited half a minute on every open with every road already on the device. The
// clock is held still, so every answer reads as one from the cache; a join that
// still waited its turn would wait 1.1 s of real time, past what waitFor gives it.
const clock = vi.spyOn(performance, 'now').mockImplementation(() => 10_000)
try {
const MUNICH: [number, number] = [48.1374, 11.5755]
const INNSBRUCK: [number, number] = [47.2692, 11.4041]
const BOZEN: [number, number] = [46.4983, 11.3548]
setting({ roadtrip_connect_days: true })
const daysList = [day(1, 1), day(2, 2), day(3, 3)]
const assignments = {
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
...map(2, [{ id: 3, at: BERLIN, time: '09:00' }, { id: 4, at: MUNICH }]),
...map(3, [{ id: 5, at: INNSBRUCK, time: '09:00' }, { id: 6, at: BOZEN }]),
} as AssignmentsMap
renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => {
expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1)
expect(askedFor(MUNICH, INNSBRUCK)).toHaveLength(1)
})
} finally {
clock.mockRestore()
}
})
it('FE-ROADTRIP-ROUTES-051: a join OSRM answered in the engine’s place is drawn, flagged, and asked for again on request', async () => {
// Filed like any answer, it stood in for the weighed road until the page was loaded
// again, and a picker on it read the stand-in line as the road already taken.
@@ -692,11 +692,19 @@ export function useRoadtripRoutes(
* for both, so a choice being checked cannot fire into the gap a seam is keeping.
*/
const lastSpacedRequestAt = useRef(0)
/** Waits until the gap since the last such request is kept, then claims the next slot. */
const spacedRequest = useCallback(async (signal: AbortSignal): Promise<void> => {
/**
* Waits until the gap since the last such request is kept, then claims the next slot.
* Hands back what gives the slot up again, for a request the cache answered.
*/
const spacedRequest = useCallback(async (signal: AbortSignal): Promise<() => void> => {
const since = performance.now() - lastSpacedRequestAt.current
if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, signal)
if (!signal.aborted) lastSpacedRequestAt.current = performance.now()
const before = lastSpacedRequestAt.current
const claimed = performance.now()
if (!signal.aborted) lastSpacedRequestAt.current = claimed
return () => {
if (lastSpacedRequestAt.current === claimed) lastSpacedRequestAt.current = before
}
}, [])
useEffect(() => {
@@ -727,7 +735,7 @@ export function useRoadtripRoutes(
// shared routing hosts answered the tail with 429, and a refused seam
// writes no state, so nothing changed to make the effect try again: the
// map drew the trip in pieces and the totals came back short.
await spacedRequest(controller.signal)
const release = await spacedRequest(controller.signal)
if (controller.signal.aborted) return
try {
// The via points on this seam, threaded in the same way the day runs thread
@@ -735,6 +743,7 @@ export function useRoadtripRoutes(
// shape: it is asked for on its own, so the points the traveller dropped on it
// never reached the router and dragging one did visibly nothing.
const shaping = viasOnLeg(seam.from, seam.to, viasByDay[seam.from.ownerDayId] ?? [])
const startedAt = performance.now()
const r = await calculateRouteWithLegs(
[
{ lat: seam.from.lat, lng: seam.from.lng },
@@ -743,6 +752,10 @@ export function useRoadtripRoutes(
],
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: seam.dayId, avoid },
)
// A seam this device routed before came back from its cache and went nowhere, so
// its slot goes back before the store below re-runs this effect. Holding it made a
// trip of connected days wait a second per join on every open (#2794).
if (performance.now() - startedAt <= CACHE_HIT_MS) release()
if (controller.signal.aborted) return
// One leg per waypoint PAIR, so a shaped seam comes back in pieces and the rail
// wants the whole drive: merged here, exactly as a day run folds its via legs
@@ -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>
@@ -212,7 +212,7 @@ function TourTimeFields({ planner, locked }: TourFieldProps) {
id="tour-planner-planned-duration"
label={t('tours.planner.plannedTotalDuration')}
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes}
invalid={planner.plannedDurationInvalid}
invalid={planner.plannedDurationInvalid || planner.plannedTotalInvalid}
errorId="tour-planner-planned-duration-error"
locked={locked}
onCommit={planner.setPlannedDurationMinutes}
@@ -220,6 +220,10 @@ function TourTimeFields({ planner, locked }: TourFieldProps) {
{planner.plannedDurationInvalid && (
<FieldError id="tour-planner-planned-duration-error">{t('tours.planner.timeValueInvalid')}</FieldError>
)}
{/* #2869: over a day a stage Save waits, and says why rather than only greying out. */}
{!planner.plannedDurationInvalid && planner.plannedTotalInvalid && (
<FieldError id="tour-planner-planned-duration-error">{t('tours.planner.plannedTotalOverDay')}</FieldError>
)}
</div>
</div>
);
@@ -337,7 +341,11 @@ export function TourDetailsSection({
}: TourFieldProps & { onDifficultyChange: (value: Difficulty) => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(readDetailsOpen);
const forcedOpen = planner.websiteInvalid || planner.plannedDurationInvalid || planner.breakAdditionalInvalid;
const forcedOpen =
planner.websiteInvalid ||
planner.plannedDurationInvalid ||
planner.breakAdditionalInvalid ||
planner.plannedTotalInvalid;
const expanded = open || forcedOpen;
const toggle = () => {
const next = !expanded;
@@ -526,6 +526,18 @@ describe('TourPlannerRail', () => {
expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole value from 0 to 1,440 minutes.')
})
it('says why Save waits on a tour over a day a stage, and opens the details to say it (#2869)', () => {
window.localStorage.clear()
render(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, canSave: false,
walkingDurationMinutes: 1500, plannedTotalMinutes: 1500, plannedTotalInvalid: true,
})} />)
expect(screen.getByLabelText(/Planned total duration/)).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByRole('alert')).toHaveTextContent(
'Walking time and breaks add up to more than 24 hours a day. Make it a multi-day tour with “End stage here” at a stop, or enter a shorter planned total.',
)
})
it('RS-02: Info controls are keyboard focusable and explain themselves only through the shared tooltip', async () => {
render(<TourPlannerRail planner={planner({
mode: { type: 'new-draft' }, hasUnsavedChanges: true, walkingDurationMinutes: 80,
@@ -1,4 +1,4 @@
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-024
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-027
import type { ComponentProps } from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor, within } from '../../../../../tests/helpers/render'
@@ -137,6 +137,12 @@ beforeEach(() => {
vi.spyOn(docsyncApi, 'createLink').mockResolvedValue({})
vi.spyOn(docsyncApi, 'items').mockResolvedValue([])
vi.spyOn(docsyncApi, 'resolve').mockResolvedValue({ success: true })
vi.spyOn(docsyncApi, 'deleteConnection').mockImplementation(async (_tripId, id) => {
// The bindings go with their connection, as ON DELETE CASCADE has it.
connections = connections.filter(c => c.id !== id)
links = links.filter(l => l.connectionId !== id)
return { success: true }
})
})
/** The status route with a number of conflicts on it, holdings as usual. */
@@ -528,3 +534,45 @@ describe('MDocSyncSheet: what a member may do', () => {
expect(screen.queryByRole('button', { name: /Papra/ })).not.toBeInTheDocument()
})
})
describe('MDocSyncSheet: the stored connection', () => {
const removeButtons = () => screen.getAllByRole('button', { name: 'Remove connection' })
it('FE-DOCSYNC-MOBILE-025: a binding opens its connection at any time, and removing it asks first', async () => {
renderSheet()
await openDetail()
fireEvent.click(screen.getByRole('button', { name: 'Edit connection' }))
expect(await screen.findByPlaceholderText('https://paperless.example')).toHaveValue('https://paperless.example')
fireEvent.click(removeButtons()[0])
expect(docsyncApi.deleteConnection).not.toHaveBeenCalled()
expect(await screen.findByText(/TREK forgets the address and login/)).toBeInTheDocument()
// The confirmation sheet opens over the form, so its button is the last one of that name.
const named = removeButtons()
fireEvent.click(named[named.length - 1])
await waitFor(() => expect(docsyncApi.deleteConnection).toHaveBeenCalledWith(TRIP_ID, 5))
// Back at the list, with Paperless offered again from scratch.
expect(await screen.findByText('Connect a provider')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-MOBILE-026: a stored connection without a binding can be edited from the folder picker', async () => {
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: /Nextcloud/ }))
await screen.findByText('Photos')
fireEvent.click(screen.getByRole('button', { name: 'Edit connection' }))
expect(await screen.findByRole('button', { name: 'Remove connection' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it('FE-DOCSYNC-MOBILE-027: a member sees no way to the connection', async () => {
renderSheet({ canManage: false })
await openDetail()
expect(screen.queryByRole('button', { name: 'Edit connection' })).not.toBeInTheDocument()
})
})
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import {
ArrowLeft, ArrowRight, Check, ChevronLeft, ChevronRight, FolderPlus, FolderSync, KeyRound, Link2Off,
Loader2, Plus, RefreshCw,
Loader2, Plus, RefreshCw, Trash2,
} from 'lucide-react'
import MSheet from '../../../components/MSheet'
import MIconBtn from '../../../components/MIconBtn'
@@ -149,6 +149,7 @@ export default function MDocSyncSheet({
provider={pending}
sync={sync}
onDone={connected}
onRemoved={() => { setReconnectFor(null); setPending(null); setView('list') }}
/>
) : view === 'scope' && pending && sync.connectionFor(pending.id) ? (
<ScopeView
@@ -156,6 +157,7 @@ export default function MDocSyncSheet({
suggestedName={slugFor(tripTitle, tripId)}
sync={sync}
onBound={() => setView('list')}
onEditConnection={() => setView('connect')}
/>
) : view === 'detail' && link ? (
<DetailView
@@ -417,6 +419,19 @@ function DetailView({
</button>
</SettingRow>
{onReconnect && (
<SettingRow label={t('docsync.connection.title')} hint={t('docsync.connection.hint')}>
<button
type="button"
onClick={onReconnect}
className="flex items-center gap-1.5 rounded-full bg-[color:var(--m-ic)] px-3 py-[6px] font-geist text-caption font-bold text-m-ink"
>
<KeyRound size={12} strokeWidth={2.2} />
{t('docsync.connection.edit')}
</button>
</SettingRow>
)}
<button
type="button"
onClick={onUnlink}
@@ -443,14 +458,18 @@ function ConnectView({
provider,
sync,
onDone,
onRemoved,
}: {
provider: DocSyncProvider
sync: ReturnType<typeof useDocSync>
onDone: () => void
onRemoved: () => void
}) {
const { t } = useTranslation()
const form = useConnectForm(provider, sync.connectionFor(provider.id), sync)
const existing = sync.connectionFor(provider.id)
const form = useConnectForm(provider, existing, sync)
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
const [confirmRemove, setConfirmRemove] = useState(false)
return (
<div className="mt-3 flex flex-col gap-3">
@@ -534,6 +553,32 @@ function ConnectView({
{sync.busy === 'save' ? <Loader2 size={15} className="mx-auto animate-spin" /> : t('common.save')}
</button>
</div>
{existing && (
<button
type="button"
onClick={() => setConfirmRemove(true)}
disabled={sync.busy === 'connection'}
className="flex h-11 w-full items-center justify-center gap-2 rounded-2xl border border-[color:var(--m-rowbr)] text-body font-bold text-[color:var(--m-st-danger)] disabled:opacity-50"
>
<Trash2 size={15} strokeWidth={2.2} />
{t('docsync.connection.remove')}
</button>
)}
<MConfirmSheet
open={confirmRemove}
onClose={() => setConfirmRemove(false)}
title={t('docsync.connection.remove')}
message={t('docsync.connection.confirmRemove')}
confirmLabel={t('docsync.connection.remove')}
cancelLabel={t('common.cancel')}
danger
onConfirm={() => {
setConfirmRemove(false)
if (existing) void sync.removeConnection(existing.id).then(ok => { if (ok) onRemoved() })
}}
/>
</div>
)
}
@@ -544,11 +589,14 @@ function ScopeView({
suggestedName,
sync,
onBound,
onEditConnection,
}: {
connectionId: number
suggestedName: string
sync: ReturnType<typeof useDocSync>
onBound: () => void
/** Back to the stored connection, to change the login or remove it before choosing a folder. */
onEditConnection: () => void
}) {
const { t } = useTranslation()
const { scopes, error, name, setName, working, bind, createAndBind } = useScopePicker({
@@ -562,6 +610,15 @@ function ScopeView({
<div className="mt-3 flex flex-col gap-3">
<p className="font-geist text-[0.71875rem] leading-snug text-m-muted">{t('docsync.scope.intro')}</p>
<button
type="button"
onClick={onEditConnection}
className="flex h-9 items-center justify-center gap-1.5 self-start rounded-full bg-[color:var(--m-ic)] px-3 font-geist text-caption font-bold text-m-ink"
>
<KeyRound size={12} strokeWidth={2.2} />
{t('docsync.connection.edit')}
</button>
<div className="flex gap-2">
<input
value={name}
+3 -3
View File
@@ -13,9 +13,9 @@ import { studioLayerActions, type StudioLayerActions } from './studioLayers'
* The book being edited.
*
* History is snapshot-based rather than patch-based, and deliberately so: a
* document is a few hundred kilobytes of plain JSON at most (the schema caps it
* at 150 spreads of 60 elements), and a snapshot stack that cannot possibly
* disagree with the document beats a patch stack that can. It is capped so a
* snapshot shares every page it did not change with the one before it, so even
* a book of hundreds of pages costs one page per step, and a snapshot stack
* that cannot possibly disagree with the document beats a patch stack that can. It is capped so a
* long session does not grow without limit.
*
* Granularity is the part that matters for how it *feels*: a drag is one undo
@@ -268,9 +268,11 @@ describe('the entry spreads', () => {
})
describe('the document as a whole', () => {
it('stays within the schema cap on spreads', () => {
it('lays out a long journey whole, its last page and back cover after the entries', () => {
const many = Array.from({ length: 200 }, (_, i) => entry({ id: i + 1 }))
expect(build({ entries: many }).spreads.length).toBeLessThanOrEqual(150)
const doc = build({ entries: many })
expect(doc.spreads.length).toBeGreaterThan(150)
expect(doc.spreads.slice(-2).map(sp => sp.role)).toEqual(['last', 'back'])
})
it('gives every spread and every element an id of its own', () => {
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "@trek/root",
"version": "5.0.0",
"version": "5.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@trek/root",
"version": "5.0.0",
"version": "5.0.1",
"workspaces": [
"client",
"server",
@@ -29,7 +29,7 @@
},
"client": {
"name": "@trek/client",
"version": "5.0.0",
"version": "5.0.1",
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
@@ -25005,7 +25005,7 @@
},
"server": {
"name": "@trek/server",
"version": "5.0.0",
"version": "5.0.1",
"dependencies": {
"@aws-lite/client": "^0.23.7",
"@aws-lite/s3": "^0.2.7",
@@ -25384,7 +25384,7 @@
},
"shared": {
"name": "@trek/shared",
"version": "5.0.0",
"version": "5.0.1",
"dependencies": {
"isomorphic-dompurify": "^3.23.0",
"zod": "^4.6.7"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@trek/root",
"private": true,
"version": "5.0.0",
"version": "5.0.1",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/server",
"version": "5.0.0",
"version": "5.0.1",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
+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
@@ -7,7 +7,7 @@
"src/nest/budget/budget.mcp.ts": 36,
"src/nest/collab/collab.mcp.ts": 20,
"src/nest/collections/collections.mcp.ts": 14,
"src/nest/day-notes/day-notes.mcp.ts": 10,
"src/nest/day-notes/day-notes.mcp.ts": 6,
"src/nest/days/days.mcp.ts": 2,
"src/nest/doc-sync/doc-sync.mcp.ts": 1,
"src/nest/files/files.mcp.ts": 4,
+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,
+4 -2
View File
@@ -177,13 +177,15 @@ export async function buildApp(): Promise<INestApplication> {
* it is the wrong one for a photo book. A Studio document is sent WHOLE on
* every autosave — deliberately, see book-store.schema.ts, because a patch
* protocol would need an ordering guarantee and a merge rule per field — and
* the contract already caps it at 150 spreads of 90 elements. A real book of
* a fortnight's journey is well past a hundred kilobytes before anybody asks
* a book has as many pages as its journey needs. A real book of a
* fortnight's journey is well past a hundred kilobytes before anybody asks
* for road geometry, and what the ceiling produces is not an error message
* but a save that quietly fails and an editor that says "not saved" without
* saying why.
*
* So the book route is measured against the size a book can actually be.
* An auto laid page is two to five kilobytes, so eight megabytes hold a
* book of a few thousand pages: the page count has no limit of its own.
*
* A list file is the same story at a smaller scale. Its contract allows a
* megabyte and the import posts it whole, yet a list of a few hundred places
+6
View File
@@ -375,6 +375,12 @@ const aiProviderStateSchema = z.discriminatedUnion('adapter', [
adapter: z.literal('anthropic-messages'),
blocks: z.array(z.record(z.string(), z.unknown())),
}),
// Gemini's thought signature per tool call id, which its Chat Completions endpoint wants back with the call.
z.object({
adapter: z.literal('openai-chat'),
model: z.string(),
signatures: z.record(z.string(), z.string()),
}),
]);
export type AiProviderState = z.infer<typeof aiProviderStateSchema>;
@@ -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);
}
/**
@@ -1,6 +1,6 @@
import { safeFetch, safeFetchLlm } from '../../utils/ssrfGuard';
import type { AiChatClient } from './ai-chat-client.interface';
import { AI_BASE_URLS } from './ai-connections.constants';
import { AI_BASE_URLS, ownServerChatBase } from './ai-connections.constants';
import type { ResolvedAiConnection } from './ai-connections.types';
import { ChatgptAuthService } from './chatgpt-auth.service';
import { AnthropicMessagesClient } from './providers/anthropic-messages.client';
@@ -67,7 +67,7 @@ export class AiChatClientService {
return new OpenAiChatClient(
kind,
model,
(connection.baseUrl ?? '').replace(/(?<!\/)\/+$/, ''),
ownServerChatBase(connection.baseUrl),
credential.apiKey,
adminServerFetch,
);
@@ -22,6 +22,26 @@ export function aiHostOf(kind: AiConnectionKind, baseUrl: string | null): string
}
}
/**
* Where an own server's Chat Completions live. An address with a path, such as
* `https://api.groq.com/openai/v1` or `http://ollama.lan:11434/v1`, is used as
* entered. A bare server address gets `/v1`: Ollama, LM Studio, vLLM,
* llama.cpp and LiteLLM all answer there, while Ollama answers
* `/chat/completions` at its root with a 404. Connecting already found the
* model list of such an address under the root, so the chat failed where the
* connection had worked.
*/
export function ownServerChatBase(baseUrl: string | null): string {
const entered = (baseUrl ?? '').trim().replace(/(?<!\/)\/+$/, '');
try {
const url = new URL(entered);
if (url.pathname === '/' && !url.search && !url.hash) return `${entered}/v1`;
} catch {
// An address that does not parse fails on the request, as it did before.
}
return entered;
}
/** Whether OpenRouter's OAuth can send the browser back here: it only returns to an https address. */
export function openrouterOAuthAvailable(): boolean {
return getAppUrl().startsWith('https://');
@@ -91,7 +111,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;
}
}
@@ -26,16 +26,29 @@ import type { AiConnectionKind } from '@trek/shared';
/** The end of an OpenAI-compatible stream. */
const DONE = '[DONE]';
/** The host of Gemini's OpenAI-compatible endpoint, which an admin may also enter as their own server. */
const GEMINI_HOST = 'generativelanguage.googleapis.com';
function isGeminiHost(baseUrl: string): boolean {
try {
return new URL(baseUrl).hostname === GEMINI_HOST;
} catch {
return false;
}
}
/**
* The provider-neutral history as Chat Completions messages, the instructions
* and the trip card first. A tool message takes no image, so the images of a
* step's tool results follow its last result as one user message.
* step's tool results follow its last result as one user message. `model` is
* the one the request goes to: a thought signature goes back only to the model
* that signed it, since another one cannot read it.
*/
export function toChatMessages(request: AiChatRequest): Record<string, unknown>[] {
export function toChatMessages(request: AiChatRequest, model?: string): Record<string, unknown>[] {
const system = [request.system, request.context].filter(Boolean).join('\n\n');
const out: Record<string, unknown>[] = [{ role: 'system', content: system }];
request.messages.forEach((message, index) => {
out.push(toChatMessage(message));
out.push(toChatMessage(message, model));
const withImages = toolImagesEndingAt(request.messages, index);
if (withImages.length === 0) return;
const content = withImages.flatMap((tool) => [
@@ -51,12 +64,14 @@ function imagePart(image: AiImage): Record<string, unknown> {
return { type: 'image_url', image_url: { url: imageDataUrl(image), detail: image.detail ?? 'high' } };
}
function toChatMessage(message: AiChatMessage): Record<string, unknown> {
function toChatMessage(message: AiChatMessage, model: string | undefined): Record<string, unknown> {
if (message.role === 'user') {
if (!message.images?.length) return { role: 'user', content: message.text };
return { role: 'user', content: [{ type: 'text', text: message.text }, ...message.images.map(imagePart)] };
}
if (message.role === 'tool') return { role: 'tool', tool_call_id: message.toolCallId, content: message.output };
const state = message.providerState;
const signatures = state?.adapter === 'openai-chat' && state.model === model ? state.signatures : {};
return {
role: 'assistant',
content: message.text || null,
@@ -66,19 +81,32 @@ function toChatMessage(message: AiChatMessage): Record<string, unknown> {
id: call.id,
type: 'function',
function: { name: call.name, arguments: JSON.stringify(call.args) },
...(signatures[call.id] ? { extra_content: { google: { thought_signature: signatures[call.id] } } } : {}),
})),
}
: {}),
};
}
/** Gemini signs a tool call on its first piece; the call has to go back with it or the next request is refused. */
function thoughtSignatureOf(piece: Record<string, unknown>): string {
return asText(asRecord(asRecord(piece.extra_content).google).thought_signature);
}
/** The id a call goes on with; a provider that names none gets one from the call's position. */
function callId(index: number, id: string): string {
return id || `call_${index}`;
}
const FINISH_REASONS: Record<string, AiFinishReason> = { tool_calls: 'tool-calls', length: 'length' };
/**
* The OpenAI-compatible Chat Completions API, streamed: OpenAI itself, Gemini
* (its `/v1beta/openai` endpoint), OpenRouter and an admin's own server. The
* kind decides only what a request may carry: Gemini gets its narrower tool
* schemas, OpenAI the prompt cache key, which the others do not know.
* schemas, also when an admin enters its endpoint as their own server, and
* OpenAI the prompt cache key, which the others do not know. Gemini's thought
* signatures come back as the turn's provider state.
*/
export class OpenAiChatClient implements AiChatClient {
/** OpenRouter searches the web for any model through its web plugin; the others have no search here. */
@@ -86,20 +114,25 @@ export class OpenAiChatClient implements AiChatClient {
return this.kind === 'openrouter';
}
/** The kind whose tool schemas the endpoint takes. */
private readonly schemaKind: AiConnectionKind;
constructor(
readonly kind: AiConnectionKind,
readonly model: string,
private readonly baseUrl: string,
private readonly apiKey: string | null,
private readonly fetcher: AiFetch,
) {}
) {
this.schemaKind = kind === 'custom' && isGeminiHost(baseUrl) ? 'gemini' : kind;
}
async *stream(request: AiChatRequest, signal: AbortSignal): AsyncGenerator<AiChatEvent> {
const body = {
model: this.model,
stream: true,
stream_options: { include_usage: true },
messages: toChatMessages(request),
messages: toChatMessages(request, this.model),
...(request.tools.length > 0
? {
tools: request.tools.map((tool) => ({
@@ -107,7 +140,7 @@ export class OpenAiChatClient implements AiChatClient {
function: {
name: tool.name,
description: tool.description,
parameters: toProviderSchema(tool.parameters, this.kind),
parameters: toProviderSchema(tool.parameters, this.schemaKind),
},
})),
tool_choice: 'auto',
@@ -153,10 +186,22 @@ export class OpenAiChatClient implements AiChatClient {
cachedInputTokens: turn.cachedInputTokens,
outputTokens: turn.outputTokens,
};
const signatures = turn.signatures();
if (Object.keys(signatures).length > 0) {
yield { type: 'provider-state', state: { adapter: 'openai-chat', model: this.model, signatures } };
}
yield { type: 'finish', reason: turn.finishReason() };
}
}
/** A tool call while it streams in: its id, name and arguments so far, and Gemini's signature. */
interface PendingCall {
id: string;
name: string;
args: string;
signature: string;
}
/**
* What one streamed Chat Completions turn has said so far. A tool call
* arrives in pieces keyed by its index (its id and name in the first, the
@@ -168,7 +213,7 @@ class ChatTurn {
outputTokens = 0;
reason: string | null = null;
done = false;
private readonly pending = new Map<number, { id: string; name: string; args: string }>();
private readonly pending = new Map<number, PendingCall>();
private readonly cited = new Set<string>();
private fresh: AiSource[] = [];
@@ -199,10 +244,11 @@ class ChatTurn {
const piece = asRecord(raw);
const index = typeof piece.index === 'number' ? piece.index : position;
const fn = asRecord(piece.function);
const call = this.pending.get(index) ?? { id: '', name: '', args: '' };
const call = this.pending.get(index) ?? { id: '', name: '', args: '', signature: '' };
call.id ||= asText(piece.id);
call.name ||= asText(fn.name);
call.args += asText(fn.arguments);
call.signature ||= thoughtSignatureOf(piece);
this.pending.set(index, call);
});
return asText(delta.content);
@@ -216,10 +262,24 @@ class ChatTurn {
}
calls(): AiToolCall[] {
return [...this.pending.entries()]
.sort(([a], [b]) => a - b)
.filter(([, call]) => call.name)
.map(([index, call]) => ({ id: call.id || `call_${index}`, name: call.name, args: parseToolArgs(call.args) }));
return this.named().map(([index, call]) => ({
id: callId(index, call.id),
name: call.name,
args: parseToolArgs(call.args),
}));
}
/** The thought signature of each signed call, by the id the call goes back with. */
signatures(): Record<string, string> {
return Object.fromEntries(
this.named()
.filter(([, call]) => call.signature)
.map(([index, call]) => [callId(index, call.id), call.signature]),
);
}
private named(): [number, PendingCall][] {
return [...this.pending.entries()].sort(([a], [b]) => a - b).filter(([, call]) => call.name);
}
finishReason(): AiFinishReason {
@@ -59,11 +59,21 @@ export async function refusalOf(kind: AiConnectionKind, res: Response): Promise<
// refusal the provider's own code already explains (a plan that does not cover it) keeps that code.
const image = namesImageInput(res.status, body) && providerFailure(kind, res.status, named).code === 'generic';
const code = image ? IMAGE_INPUT_REFUSED : named;
// Status and code only: an error body can quote the request, and the request holds the trip.
logWarn(`[ai] ${kind} refused the request: HTTP ${res.status}${code ? ` (${code})` : ''}`);
// Status, code and the refused field's path only: an error message can quote the request, and the
// request holds the trip. The path alone says which part of a replayed conversation the provider refused.
const param = refusedParamOf(body);
logWarn(
`[ai] ${kind} refused the request: HTTP ${res.status}${code ? ` (${code})` : ''}${param ? ` at ${param}` : ''}`,
);
return providerFailure(kind, res.status, code);
}
/** The field an error body names as refused (OpenAI's `param`, such as `input[12]`), when it is a bare path. */
function refusedParamOf(body: unknown): string | undefined {
const param = asRecord(asRecord(body).error).param;
return typeof param === 'string' && /^[\w.[\]-]{1,80}$/.test(param) ? param : undefined;
}
/** One SSE data payload as a JSON object; null for anything else (a `[DONE]`, a keepalive). */
export function parseEventData(data: string): Record<string, unknown> | null {
try {
+22 -38
View File
@@ -9,6 +9,7 @@ import { fitToSize } from './assistant-output';
import {
ADD_FOUND_PLACE_TOOL,
APPROVAL_TTL_MS,
ASSISTANT_MODEL_CALLED_SERVER_TOOLS,
ASSISTANT_SERVER_TOOLS,
IMPORT_BOOKING_TOOL,
IMPORT_TOUR_FILE_TOOL,
@@ -190,13 +191,6 @@ function earlierCut(text: string, earlier: boolean): string {
: text;
}
/**
* The model's own calls on attached files: told after the step that made
* them, so the history ends on the user's side when the user's decision on
* them goes on.
*/
const ATTACHED_FILE_TOOLS: ReadonlySet<string> = new Set([READ_ATTACHED_FILE_TOOL, KEEP_ATTACHED_FILE_TOOL]);
/** What became of keeping an attached file in Files, as its stored part says. */
function keptFileLine(part: ToolCallPart): string {
const file = quoted(part.args.name);
@@ -234,30 +228,31 @@ function attachedReadLine(
}
/**
* What the model is told of its reads of attached files in one step: the
* text of each file the user let it read, the first time the history holds
* it (`told` keeps track), and what became of a read the user did not allow.
* `texts` are the shared files' texts, by attachment id; one no longer there
* was swept with its file.
* The answer a replayed call gets: what became of an attached file (the text
* of a file the user let it read, the first time the history holds it, `told`
* keeping track; `texts` are the shared files' texts by attachment id), "not
* applied" for a held change that never ran, the stored result otherwise.
*/
function attachedReadNote(
calls: readonly ToolCallPart[],
function replayedOutput(
part: ToolCallPart,
texts: ReadonlyMap<string, string>,
told: Set<string>,
earlier: boolean,
): string {
return calls
.map((part) =>
part.toolName === KEEP_ATTACHED_FILE_TOOL ? keptFileLine(part) : attachedReadLine(part, texts, told, earlier),
)
.join('\n\n');
if (part.toolName === KEEP_ATTACHED_FILE_TOOL) return keptFileLine(part);
if (part.toolName === READ_ATTACHED_FILE_TOOL) return attachedReadLine(part, texts, told, earlier);
if (part.result === undefined) return NOT_APPLIED;
return outputText(earlier ? fitToSize(part.result, EARLIER_TOOL_OUTPUT_CHARS) : part.result);
}
/**
* The thread as the provider reads it, oldest first, from its first user
* message on. A step's tool calls are followed by their answers; a held
* change that was never applied answers that it was not, since every call
* must be answered. `providerIds` maps a held call's own id to the id the
* must be answered. That includes the server's tools the model called itself
* (a trip note, an attached file): the providers that replay their own record
* of a step refuse it with a call left unanswered, and every later message of
* the thread with it. `providerIds` maps a held call's own id to the id the
* provider gave it.
*/
export function toChatHistory(
@@ -282,42 +277,31 @@ export function toChatHistory(
(part): part is Extract<AssistantMessagePart, { type: 'tool-call' }> =>
part.type === 'tool-call' && part.toolName !== ASSISTANT_WEB_SEARCH_STEP,
);
const reads = allCalls.filter((part) => ATTACHED_FILE_TOOLS.has(part.toolName));
const serverCalls = allCalls.filter(
(part) => ASSISTANT_SERVER_TOOLS.has(part.toolName) && !ATTACHED_FILE_TOOLS.has(part.toolName),
);
const serverStep = (part: ToolCallPart) =>
ASSISTANT_SERVER_TOOLS.has(part.toolName) && !ASSISTANT_MODEL_CALLED_SERVER_TOOLS.has(part.toolName);
// Told as the user's side of the conversation: the history then ends on it
// when a decision on these bookings starts the next answer.
if (serverCalls.length > 0) history.push({ role: 'user', text: serverStepsNote(serverCalls) });
const calls = allCalls.filter((part) => !ASSISTANT_SERVER_TOOLS.has(part.toolName));
const note = serverStepsNote(allCalls.filter(serverStep));
if (note) history.push({ role: 'user', text: note });
const calls = allCalls.filter((part) => !serverStep(part));
const toolCalls: AiToolCall[] = calls.map((part) => ({
id: providerIds.get(part.toolCallId) ?? part.toolCallId,
name: part.toolName,
args: part.args,
}));
const text = textOf(parts);
// Told after the step that read the files, so the history ends on the user's side when a consent goes on.
const readNote = reads.length > 0 ? attachedReadNote(reads, attachedTexts, told, earlier) : '';
if (!text && toolCalls.length === 0) {
if (readNote) history.push({ role: 'user', text: readNote });
return;
}
if (!text && toolCalls.length === 0) return;
const providerState = decodeJson(ASSISTANT_PROVIDER_STATE, row.provider_state, `message ${row.id}`);
history.push({ role: 'assistant', text, toolCalls, providerState });
calls.forEach((part, index) => {
const output =
part.result === undefined
? NOT_APPLIED
: outputText(earlier ? fitToSize(part.result, EARLIER_TOOL_OUTPUT_CHARS) : part.result);
history.push({
role: 'tool',
toolCallId: toolCalls[index].id,
name: part.toolName,
output,
output: replayedOutput(part, attachedTexts, told, earlier),
...(part.isError ? { isError: true } : {}),
});
});
if (readNote) history.push({ role: 'user', text: readNote });
});
return history;
}
@@ -255,6 +255,18 @@ export const ASSISTANT_SERVER_TOOLS: ReadonlySet<string> = new Set([
...Object.keys(ASSISTANT_SERVER_WRITE_TOOLS),
]);
/**
* The server's tools the model calls itself. TREK answers them, but they are
* calls in the provider's own record of the step, so a replayed step has to
* answer them like any other; the rest of the server's tools are steps the
* server took, which the provider never saw.
*/
export const ASSISTANT_MODEL_CALLED_SERVER_TOOLS: ReadonlySet<string> = new Set([
REMEMBER_TOOL,
READ_ATTACHED_FILE_TOOL,
KEEP_ATTACHED_FILE_TOOL,
]);
/** The largest file the assistant reads. */
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
@@ -195,7 +195,7 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
const dayId = id(args, 'dayId');
const rows = new Rows()
.add('day', dayLabel(ctx, dayId))
.add('time', text(args.time))
.add('content', text(args.time))
.add('icon', text(args.icon))
.add('color', text(args.color));
return { subject: text(args.text), rows, dayId, placeIds: [] };
@@ -203,8 +203,8 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
update_day_note: (ctx, args) => {
const note = ctx.card.notes.get(id(args, 'noteId') ?? 0);
const rows = new Rows();
if (has(args, 'text')) rows.change('text', note?.text ?? null, text(args.text));
if (has(args, 'time')) rows.change('time', note?.time ?? null, text(args.time));
if (has(args, 'text')) rows.change('title', note?.text ?? null, text(args.text));
if (has(args, 'time')) rows.change('content', note?.time ?? null, text(args.time));
if (has(args, 'icon')) rows.change('icon', note?.icon ?? null, text(args.icon));
if (has(args, 'color')) rows.change('color', note?.color ?? null, text(args.color));
return { subject: note?.text ?? text(args.text), rows, dayId: id(args, 'dayId'), placeIds: [] };
+27 -13
View File
@@ -11,7 +11,13 @@ import {
} from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { DayNotesService } from './day-notes.service';
import { idSchema, NOTE_COLORS, type NoteColor } from '@trek/shared';
import {
dayNoteCreateRequestSchema,
dayNoteUpdateRequestSchema,
idSchema,
NOTE_COLORS,
type NoteColor,
} from '@trek/shared';
import { z } from 'zod';
@@ -23,6 +29,18 @@ import { z } from 'zod';
*/
const noteColorSchema = z.enum(NOTE_COLORS);
/**
* The note's two text fields carry their names from the time a note was a line
* with a clock time: `text` is the heading and `time` the Markdown body under it.
* The REST contract holds the bounds, so a note written in the planner can always
* be edited here; the descriptions are what tells a model which field is which.
*/
const NOTE_TITLE = 'The note title, shown as its heading in the day plan. Keep it short';
const NOTE_BODY =
'The note body shown under the title: details, links, reminders, Markdown allowed. ' +
'Despite the field name this is the content, not a clock time';
const noteTitleSchema = dayNoteCreateRequestSchema.shape.text.describe(NOTE_TITLE);
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
return Number.isInteger(n) && n > 0 ? n : null;
@@ -52,12 +70,12 @@ export class DayNotesMcp {
@Tool({
name: 'create_day_note',
description: 'Add a note to a specific day in a trip.',
description: 'Add a note to a specific day in a trip: a title in `text` and its body in `time`.',
inputSchema: {
tripId: idSchema,
dayId: idSchema,
text: z.string().min(1).max(500),
time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'),
text: noteTitleSchema,
time: dayNoteCreateRequestSchema.shape.time.describe(NOTE_BODY),
icon: z.string().max(64).optional().describe('Emoji icon for the note'),
color: noteColorSchema
.nullable()
@@ -86,7 +104,7 @@ export class DayNotesMcp {
tripId: number;
dayId: number;
text: string;
time?: string;
time?: string | null;
icon?: string;
color?: NoteColor | null;
sort_order?: number;
@@ -104,18 +122,14 @@ export class DayNotesMcp {
@Tool({
name: 'update_day_note',
description: 'Edit an existing note on a specific day.',
description: 'Edit an existing note on a specific day: its title is `text`, its body `time`.',
inputSchema: {
tripId: idSchema,
dayId: idSchema,
noteId: idSchema,
text: z.string().min(1).max(500).optional(),
time: z
.string()
.max(250)
.nullable()
.optional()
.describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
// The create bound, so an update cannot blank the title the planner requires.
text: noteTitleSchema.optional(),
time: dayNoteUpdateRequestSchema.shape.time.describe(`${NOTE_BODY}, or null to clear it`),
icon: z.string().max(64).optional().describe('Emoji icon for the note'),
color: noteColorSchema
.nullable()
+1 -2
View File
@@ -1,7 +1,6 @@
import {
bookSpreadPhotoIds,
compareCodeUnits,
MAX_SPREADS,
type BookDesignPlanRow,
type BookDesignSkipped,
type BookDocument,
@@ -176,7 +175,7 @@ export function applyDesignDraft(
if (at < 0 || !samePage(spreads[at], base.get(row.spreadId))) return skip(row.spreadId, 'changed_since');
spreads[at] = page;
} else {
if (at >= 0 || spreads.length >= MAX_SPREADS) return skip(row.spreadId, 'changed_since');
if (at >= 0) return skip(row.spreadId, 'changed_since');
spreads.splice(insertAt(spreads, draft.plan, i, page), 0, page);
}
applied.push(row.spreadId);
@@ -178,7 +178,7 @@ export class JourneyBookDesignService {
baseVersion: draft.baseVersion,
expiresAt: new Date(draft.expiresAt).toISOString(),
document: draft.doc,
plan: draft.plan.slice(0, BOOK_DESIGN_LIMITS.selection).map(planRowOut),
plan: draft.plan.map(planRowOut),
selection: draft.selection,
};
}
@@ -376,7 +376,7 @@ function answerOf(draft: BookDesignDraft): BookDesignAnswer {
expires_at: new Date(draft.expiresAt).toISOString(),
plan: rows,
more_rows: draft.plan.length - rows.length,
selected: draft.selection.slice(0, BOOK_DESIGN_LIMITS.selection),
selected: draft.selection,
style: draft.style,
photos: {
used: draft.usedPhotos,
+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') };
}
+73 -5
View File
@@ -86,11 +86,67 @@ export async function reclaimPhotoCache(
export interface DedupSet {
names: Set<string>;
/**
* The places with a line, keyed by name and course (`trackKey`): a track to import
* matches only these (#2810), and only one that runs the same way (#2870). Absent
* where none is read.
*/
tracks?: 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), with that line in `routeGeometry`.
*/
export type DedupCandidate = PlaceMatchCandidate & { track?: boolean; routeGeometry?: string | null };
/**
* Where a line starts, ends and how far it reaches, to about a hundred metres: two
* hikes a watch names alike ("Afternoon Hike") differ here, while the same file read
* again gives the same answer however its points were rounded or thinned. Empty for
* a line that does not parse.
*/
function lineCourse(routeGeometry: string | null | undefined): string {
let points: unknown;
try {
points = JSON.parse(routeGeometry ?? '');
} catch {
return '';
}
if (!Array.isArray(points)) return '';
const line = points.filter(
(p): p is [number, number] => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]),
);
if (line.length === 0) return '';
// A loop rather than Math.min(...): a recorded day holds more points than a call takes arguments.
let [south, west] = line[0];
let [north, east] = line[0];
for (const [lat, lng] of line) {
south = Math.min(south, lat);
north = Math.max(north, lat);
west = Math.min(west, lng);
east = Math.max(east, lng);
}
const [first, last] = [line[0], line[line.length - 1]];
return [first[0], first[1], last[0], last[1], south, west, north, east].map((v) => v.toFixed(3)).join(',');
}
/** An imported row as the dedup set reads it: a line when it carries one. */
export function lineCandidate(
name: string | null | undefined,
row: { lat: number | null; lng: number | null; routeGeometry?: string | null },
): DedupCandidate {
return { name, lat: row.lat, lng: row.lng, track: !!row.routeGeometry, routeGeometry: row.routeGeometry };
}
/** A track's entry in `DedupSet.tracks`: its normalized name and its course. */
function trackKey(name: string, routeGeometry: string | null | undefined): string {
return `${name}\u0000${lineCourse(routeGeometry)}`;
}
/**
* Returns true if a candidate place is already represented in the dedup set.
*
@@ -104,12 +160,19 @@ 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 that runs the same way, never as a
// sight of that name: a watch names a hike after the place it went to (#2810), and
// two hikes of one day alike (#2870).
if (candidate.track) {
if (dedup.tracks?.has(trackKey(strategy.name, candidate.routeGeometry))) return true;
} else if (dedup.names.has(strategy.name)) {
return true;
}
} else if (
dedup.coords.some(
(c) =>
@@ -130,29 +193,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 tracks = 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) tracks.add(trackKey(row.name.trim().toLowerCase(), row.route_geometry));
} else if (row.lat != null && row.lng != null) {
coords.push({ lat: row.lat, lng: row.lng });
}
}
return { names, coords, externalIds };
return { names, tracks, 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.tracks ??= new Set()).add(trackKey(normalizedName, place.routeGeometry));
} else if (place.lat != null && place.lng != null) {
dedup.coords.push({ lat: place.lat, lng: place.lng });
}
+6 -6
View File
@@ -58,7 +58,7 @@ import {
buildCategoryNameLookup,
escapeLikePattern,
dedupSetOf,
isPlaceDuplicate,
isPlaceDuplicate, lineCandidate,
mapWithConcurrency,
pickEnrichmentMatch,
reclaimPhotoCache,
@@ -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 = lineCandidate(wp.name, wp);
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(lineCandidate(wp.name, wp), 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(lineCandidate(wp.name, wp), 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(lineCandidate(name, parsedPlacemark), 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(lineCandidate(name, parsedPlacemark), 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;
}
@@ -3,6 +3,7 @@ import type { PreparedGpxPlace } from '../place-import/place-import.types';
import { PlacesService } from '../places/places.service';
import { readTourFile } from './tour-file-import.helpers';
import { gpxOfLine, splitGpxTrackPieces } from './tour-file-split.helpers';
import { tracksToAdopt } from './tour-track-adoption';
import { computeTourMetrics, parseRouteGeometry } from './tours.helpers';
import { Injectable } from '@nestjs/common';
import { TOUR_GPX_TEXT_MAX, type TourFileLeftOut } from '@trek/shared';
@@ -15,7 +16,10 @@ export interface TourFileTrack {
point_count: number;
distance_km: number;
has_heights: boolean;
/** A track already in the trip under the same name and start, which the import would skip. */
/**
* A track already in the trip under the same name and start, which the import would skip. A track
* from before Tours under the same name is none: the import takes it over as a tour (#2810).
*/
duplicate: boolean;
}
@@ -55,7 +59,9 @@ export class TourFileReadService {
async read(tripId: number, buffer: Buffer, fileName: string): Promise<TourFileTracks> {
const { rows, leftOut } = await readTourFile(this.placeImport, buffer, fileName);
if (rows.length === 0) return { tracks: [], whole: false };
const duplicates = await this.places.gpxDuplicates(String(tripId), rows);
const found = await this.places.gpxDuplicates(String(tripId), rows);
const adopt = tracksToAdopt(rows, await this.places.list(String(tripId), {}));
const duplicates = found.map((duplicate, index) => duplicate && !adopt.has(index));
const extra = leftOut ? { left_out: leftOut } : {};
if (!isGpxName(fileName)) {
const tracks = rows.map((row, index) =>
@@ -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 } : {}),
@@ -11,6 +11,7 @@ import { haversineKm } from '../common/geo';
import { localParts, resolveTimeZone } from '../common/timezoneService';
import type { EndpointInput } from '../reservations/reservations.service';
import { deriveTransitStats, type TransitItinerary, type TransitLeg } from './transit.helpers';
import type { TransitProvider } from '@trek/shared';
import { z } from 'zod';
@@ -205,10 +206,12 @@ function timezoneAt(lat: number, lng: number): string {
return timezone;
}
/** `provider` is the backend the itinerary came from, recorded with the journey. */
export function buildTransitReservationParts(
from: TransitPlaceInput,
to: TransitPlaceInput,
itinerary: TransitItinerary,
provider: TransitProvider,
) {
// The same stop appears up to three times below (transfer endpoint + the
// adjacent legs' metadata) — memoise the tz lookup per coordinate pair.
@@ -276,7 +279,7 @@ export function buildTransitReservationParts(
const stats = deriveTransitStats(itinerary.startTime, itinerary.endTime, itinerary.legs, itinerary.transfers);
const metadata = {
transit: {
provider: 'transitous',
provider,
duration: stats.duration,
transfers: stats.transfers,
walk_seconds: stats.walkSeconds,
+5 -3
View File
@@ -110,7 +110,7 @@ export class TransitMcp {
@Tool({
name: 'search_transit_routes',
description:
'Search scheduled public-transit routes between two coordinates, via whichever backend the instance is configured for. Returns itineraries that can be passed unchanged to create_transit_journey. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
'Search scheduled public-transit routes between two coordinates, via whichever backend the instance is configured for. Returns itineraries that can be passed unchanged to create_transit_journey, and `provider`, the backend that answered. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
inputSchema: {
from: transitPlaceInput,
to: transitPlaceInput,
@@ -169,7 +169,7 @@ export class TransitMcp {
// A rejected itinerary is provider data we could not vouch for, but dropping it
// silently is indistinguishable from "no routes exist" — report the count so the
// caller knows the difference.
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length });
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length, provider: result.provider });
} catch (err) {
return errorResult(err, 'Transit route search failed.');
}
@@ -228,9 +228,11 @@ export class TransitMcp {
isError: true,
};
}
// The itinerary came from search_transit_routes, which the active backend answered.
const provider = await this.transit.activeProvider(ctx.userId);
let reservationParts: ReturnType<typeof buildTransitReservationParts>;
try {
reservationParts = buildTransitReservationParts(from, to, cleaned);
reservationParts = buildTransitReservationParts(from, to, cleaned, provider);
} catch (err) {
return errorResult(err, 'Unable to resolve the transit journey timezones.');
}
+7 -2
View File
@@ -159,6 +159,11 @@ export class TransitService {
@Inject(transitConfig.KEY) private readonly transitEnv: ConfigType<typeof transitConfig>,
) {}
/** The backend that answers this user's searches: Google where it is set up and keyed, Transitous otherwise. */
async activeProvider(userId = 0): Promise<TransitProvider> {
return (await this.google.isActive(userId)) ? 'google' : 'transitous';
}
/**
* Station/place search for the from/to pickers. `near` biases results.
*
@@ -175,7 +180,7 @@ export class TransitService {
const text = (query || '').trim();
// Answered before either backend is consulted, so it reports the one that
// WOULD have been asked rather than claiming nobody was.
const provider: TransitProvider = (await this.google.isActive(userId)) ? 'google' : 'transitous';
const provider = await this.activeProvider(userId);
if (text.length < 2) return { results: [], provider };
if (text.length > 200) {
const e = new Error('Query too long') as Error & { status: number };
@@ -259,7 +264,7 @@ export class TransitService {
// After validation on purpose: whichever backend answers, the caller is held
// to the same coordinate/mode/transfer contract and gets the same 400s.
if (await this.google.isActive(userId)) {
if ((await this.activeProvider(userId)) === 'google') {
return { ...(await this.google.plan(q, language, userId)), provider: 'google' };
}
+2 -2
View File
@@ -59,7 +59,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
titleKey: 'system_notice.release_notes.headline',
bodyKey: 'system_notice.release_notes.intro',
release: {
version: '5.0.0',
version: '5.0.1',
eyebrowKey: 'system_notice.release_notes.eyebrow',
headlineKey: 'system_notice.release_notes.headline',
introKey: 'system_notice.release_notes.intro',
@@ -95,7 +95,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
footnoteKey: 'system_notice.release_notes.footnote',
notes: {
labelKey: 'system_notice.release_notes.notes_label',
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.0',
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.1',
},
note: {
eyebrowKey: 'system_notice.release_notes.note_eyebrow',
+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');
});
});
+31 -7
View File
@@ -100,6 +100,16 @@ async function answer(req: IncomingMessage, res: ServerResponse): Promise<void>
let nestApp: INestApplication;
let orm: MikroORM;
let fake: HttpServer;
let adminId: number;
/** The instance connection, pointed at the fake under `path`: `/v1`, or nothing as an Ollama address is often entered. */
function connectAt(path: string) {
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}${path}`, model: 'fake-model' });
}
beforeAll(async () => {
fake = createServer((req, res) => {
@@ -110,13 +120,8 @@ beforeAll(async () => {
orm = nestApp.get(MikroORM);
await setAddonEnabled(orm, 'ai_assistant', true);
await setAddonEnabled(orm, 'atlas', true);
const adminId = (await makeUser(orm, { role: 'admin' })).user.id;
const { port } = fake.address() as AddressInfo;
const res = await request(nestApp.getHttpServer())
.put('/api/admin/ai/connection')
.set('Cookie', sessionCookie(adminId))
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/v1`, model: 'fake-model' });
expect(res.status).toBe(200);
adminId = (await makeUser(orm, { role: 'admin' })).user.id;
expect((await connectAt('/v1')).status).toBe(200);
});
beforeEach(async () => {
@@ -342,4 +347,23 @@ describe('Home assistant e2e', () => {
expect(await countRows(orm, AssistantThreads, { id: homeThread })).toBe(0);
expect(await countRows(orm, AssistantThreads, { id: tripThread })).toBe(1);
});
it('ASTHE2E-007: an Ollama address entered without /v1 chats at /v1/chat/completions, where Ollama answers', async () => {
const h = await ownerWithTrips();
expect((await connectAt('')).status).toBe(200);
try {
provider.script = [say('Hello from Ollama.')];
const res = await post(`${BASE}/chat`, h.userId, { ...CTX, threadId: null, text: 'hi' });
expect(res.status).toBe(200);
expect(provider.requests).toHaveLength(1);
expect(
ofType(res.chunks, 'text-delta')
.map((c) => c.delta)
.join(''),
).toBe('Hello from Ollama.');
expect(ofType(res.chunks, 'error')).toEqual([]);
} finally {
expect((await connectAt('/v1')).status).toBe(200);
}
});
});
+61
View File
@@ -535,6 +535,67 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
expect(await countRows(orm, Tours)).toBe(1);
});
it('imports two different tracks of the same name, and still skips either one the second time (#2870)', async () => {
// A second hike the watch called "Alpine loop" too, somewhere else entirely.
const other = TRACK_GPX.replace(/lat="49(\.\d+)?" lon="12/g, (m) => m.replace('49', '47').replace('12', '11'));
expect((await importGpx(OWNER, TRACK_GPX)).body).toMatchObject({ skipped: 0 });
expect((await previewGpx(OWNER, other)).body.tracks[0]).toMatchObject({ duplicate: false });
const second = await importGpx(OWNER, other);
expect(second.status).toBe(201);
expect(second.body).toMatchObject({ skipped: 0 });
expect(second.body.tours).toHaveLength(1);
expect(await countRows(orm, Tours)).toBe(2);
expect((await importGpx(OWNER, TRACK_GPX)).body).toEqual({ tours: [], caution: false, skipped: 1 });
expect((await importGpx(OWNER, other)).body).toEqual({ tours: [], caution: false, skipped: 1 });
expect(await countRows(orm, Tours)).toBe(2);
});
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 },
]);
});
+76
View File
@@ -99,6 +99,43 @@ describe('Tool: create_day_note', () => {
});
});
it('describes text as the title and time as the body, as the planner shows them', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const { tools } = await h.client.listTools();
for (const name of ['create_day_note', 'update_day_note']) {
const props = tools.find((t) => t.name === name)?.inputSchema.properties as Record<string, unknown>;
// The nullable body may come out as an anyOf, so read the property whole.
const time = JSON.stringify(props.time);
expect(JSON.stringify(props.text)).toMatch(/title/i);
expect(time).toMatch(/body/i);
expect(time).not.toMatch(/time label/i);
expect(time).toContain('"maxLength":2000');
}
});
});
it('takes a body of 2000 characters, the bound the planner allows, and refuses one more', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const body = 'b'.repeat(2000);
const result = await h.client.callTool({
name: 'create_day_note',
arguments: { tripId: trip.id, dayId: day.id, text: 'Museum day', time: body },
});
const data = parseToolResult(result) as { note: { id: number } };
expect((await dayNote(data.note.id)).time).toBe(body);
const tooLong = await h.client.callTool({
name: 'create_day_note',
arguments: { tripId: trip.id, dayId: day.id, text: 'Museum day', time: `${body}b` },
});
expect(tooLong.isError).toBe(true);
expect(await countRows(orm, DayNotes, { day: day.id })).toBe(1);
});
});
it('defaults icon to 📝', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -220,6 +257,45 @@ describe('Tool: update_day_note', () => {
});
});
it('edits a note whose body is longer than 250 characters without shortening it', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const longBody = 'Opening hours and the ticket links. '.repeat(20);
const note = createDayNote(testDb, day.id, trip.id, { text: 'Museum day', time: longBody });
await withHarness(user.id, async (h) => {
const renamed = await h.client.callTool({
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'Museums' },
});
expect(renamed.isError).toBeFalsy();
const longer = `${longBody}Bring the pass.`;
const rewritten = await h.client.callTool({
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, time: longer },
});
expect(rewritten.isError).toBeFalsy();
const row = await dayNote(note.id);
expect(row.text).toBe('Museums');
expect(row.time).toBe(longer);
});
});
it('refuses an empty title on update and keeps the stored one', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id, { text: 'Keep me' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: '' },
});
expect(result.isError).toBe(true);
expect((await dayNote(note.id)).text).toBe('Keep me');
});
});
it('trims text whitespace', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -201,6 +201,7 @@ describe('MCP transit tools', () => {
expect(routes.itineraries[0].legs[0].from.name).toBe('Namba');
expect(routes.itineraries[0].legs[1].to.name).toBe('Umeda');
expect(routes.itineraries).toHaveLength(1);
expect(routes.provider).toBe('transitous');
expect(planMock).toHaveBeenCalledWith(expect.objectContaining({ modes: 'SUBWAY' }), undefined, user.id);
const invalidNear = await harness.client.callTool({
@@ -244,6 +245,27 @@ describe('MCP transit tools', () => {
});
});
it('records Google as the provider of a journey planned while Google answers searches (#2850)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-04' });
const day = await dayOf(trip.id, '2026-12-03');
const active = vi.spyOn(TransitService.prototype, 'activeProvider').mockResolvedValue('google');
try {
await withHarness(user.id, ['reservations:write'], async (harness) => {
const result = parseToolResult(
await harness.client.callTool({
name: 'create_transit_journey',
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary },
}),
) as { reservation: { metadata: string } };
expect(JSON.parse(result.reservation.metadata).transit.provider).toBe('google');
});
expect(active).toHaveBeenCalledWith(user.id);
} finally {
active.mockRestore();
}
});
it('rejects dateless, mismatched, and out-of-range journey dates', async () => {
const { user } = createUser(testDb);
const datelessTrip = createTrip(testDb, user.id);
@@ -7,6 +7,7 @@ import {
aiHostOf,
aiRefusal,
openrouterOAuthAvailable,
ownServerChatBase,
providerRefusal,
} from '../../../../src/nest/ai-connections/ai-connections.constants';
import { InMemoryAiRefreshLock } from '../../../../src/nest/ai-connections/ai-refresh-lock';
@@ -127,6 +128,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');
@@ -135,6 +146,16 @@ describe('constants', () => {
expect(aiHostOf('custom', null)).toBe('');
});
it('AIHLP-032b: a bare own server address chats under /v1, an address with a path as entered', () => {
expect(ownServerChatBase('http://ollama.lan:11434')).toBe('http://ollama.lan:11434/v1');
expect(ownServerChatBase(' http://ollama.lan:11434/ ')).toBe('http://ollama.lan:11434/v1');
expect(ownServerChatBase('http://ollama.lan:11434/v1/')).toBe('http://ollama.lan:11434/v1');
expect(ownServerChatBase('https://api.groq.com/openai/v1')).toBe('https://api.groq.com/openai/v1');
expect(ownServerChatBase('https://llm.example.org/proxy')).toBe('https://llm.example.org/proxy');
expect(ownServerChatBase('not a url')).toBe('not a url');
expect(ownServerChatBase(null)).toBe('');
});
it('AIHLP-033: OpenRouter sign-in is offered only on an https app URL', () => {
vi.stubEnv('APP_URL', 'https://trip.example.org');
expect(openrouterOAuthAvailable()).toBe(true);
@@ -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(
@@ -28,6 +28,12 @@ import { toProviderSchema, type AiFetch } from '../../../../src/nest/ai-connecti
import { describe, expect, it, vi } from 'vitest';
const { logWarn } = vi.hoisted(() => ({ logWarn: vi.fn() }));
vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../src/nest/audit/audit-log.logger')>()),
logWarn,
}));
/** An SSE body from whole events, cut every `every` characters so no event arrives in one piece. */
function sseBody(events: unknown[], every = 7): ReadableStream<Uint8Array> {
const text = events.map((e) => `data: ${typeof e === 'string' ? e : JSON.stringify(e)}\n\n`).join('');
@@ -145,6 +151,40 @@ describe('provider errors', () => {
expect(errorCodeOf(null)).toBeUndefined();
expect(errorCodeOf({})).toBeUndefined();
});
it('PERR-004: a refusal is logged with the path of the field it names, never with its message (#2845)', async () => {
const refused = (param: unknown) =>
failure(
new OpenAiResponsesClient(
'm',
async () => 'at',
fetchAnswering(
jsonResponse(400, {
error: {
type: 'invalid_request_error',
code: null,
message: 'No tool output found for function call call_note. Dinner in Rome at 20:00.',
param,
},
}),
).fetcher,
).stream(REQUEST, signal()),
);
logWarn.mockClear();
expect(await refused('input[3]')).toMatchObject({ code: 'generic', status: 400 });
expect(logWarn).toHaveBeenCalledWith(
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error) at input[3]',
);
logWarn.mockClear();
await refused('input[3] Dinner in Rome');
await refused(null);
expect(logWarn.mock.calls.map(([line]) => line)).toEqual([
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error)',
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error)',
]);
});
});
describe('toProviderSchema', () => {
@@ -489,6 +529,125 @@ describe('OpenAiChatClient', () => {
await failure(client(streamResponse([{ choices: [{ delta: { content: 'half' } }] }])).stream(REQUEST, signal())),
).toMatchObject({ providerCode: 'stream_cut' });
});
// Gemini 3 signs the first call of a step on its first piece, the way its Chat Completions endpoint streams it.
const SIGNED = [
{
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: 'function-call-1',
type: 'function',
function: { name: 'create_place', arguments: '{"name":"Nijo"}' },
extra_content: { google: { thought_signature: 'sig-A' } },
},
],
},
},
],
},
{
choices: [
{
delta: {
tool_calls: [{ index: 1, function: { name: 'list_places', arguments: '{}' } }],
},
finish_reason: 'tool_calls',
},
],
},
'[DONE]',
];
it('OACH-006: a signed call comes back as the provider state, keyed by the id it goes on with', async () => {
const events = await run(
new OpenAiChatClient(
'gemini',
'gemini-3-flash',
'https://g',
'k',
fetchAnswering(streamResponse(SIGNED)).fetcher,
).stream(REQUEST, signal()),
);
expect(events.filter((event) => event.type === 'tool-call')).toEqual([
{ type: 'tool-call', call: { id: 'function-call-1', name: 'create_place', args: { name: 'Nijo' } } },
{ type: 'tool-call', call: { id: 'call_1', name: 'list_places', args: {} } },
]);
expect(events.slice(-2)).toEqual([
{
type: 'provider-state',
state: { adapter: 'openai-chat', model: 'gemini-3-flash', signatures: { 'function-call-1': 'sig-A' } },
},
{ type: 'finish', reason: 'tool-calls' },
]);
// An unsigned turn keeps no state: nothing is stored for a provider that signs nothing.
const plain = await run(
new OpenAiChatClient('openai', 'm', 'https://x', 'k', fetchAnswering(streamResponse(STREAM)).fetcher).stream(
REQUEST,
signal(),
),
);
expect(plain.some((event) => event.type === 'provider-state')).toBe(false);
});
it('OACH-007: the signature goes back with its call to the model that signed it, and to no other', async () => {
const signed: AiChatRequest = {
...REQUEST,
messages: [
{ role: 'user', text: 'Add Nijo' },
{
role: 'assistant',
text: '',
toolCalls: [
{ id: 'function-call-1', name: 'create_place', args: { name: 'Nijo' } },
{ id: 'call_1', name: 'list_places', args: {} },
],
providerState: {
adapter: 'openai-chat',
model: 'gemini-3-flash',
signatures: { 'function-call-1': 'sig-A' },
},
},
{ role: 'tool', toolCallId: 'function-call-1', name: 'create_place', output: '{"id":7}' },
{ role: 'tool', toolCallId: 'call_1', name: 'list_places', output: '[]' },
],
};
const http = fetchAnswering(streamResponse(['[DONE]']));
await run(
new OpenAiChatClient('gemini', 'gemini-3-flash', 'https://g', 'k', http.fetcher).stream(signed, signal()),
);
expect(dig(http.body(), 'messages', 2, 'tool_calls')).toEqual([
{
id: 'function-call-1',
type: 'function',
function: { name: 'create_place', arguments: '{"name":"Nijo"}' },
extra_content: { google: { thought_signature: 'sig-A' } },
},
{ id: 'call_1', type: 'function', function: { name: 'list_places', arguments: '{}' } },
]);
// A thread that moved on to another model sends the calls plain: no other model reads the signature.
for (const messages of [toChatMessages(signed, 'gpt-5-mini'), toChatMessages(signed)]) {
expect(JSON.stringify(messages)).not.toContain('extra_content');
}
});
it('OACH-008: an own server on Gemini’s endpoint gets Gemini’s narrower schemas, any other the full ones', async () => {
const parameters = async (baseUrl: string) => {
const http = fetchAnswering(streamResponse(['[DONE]']));
await run(new OpenAiChatClient('custom', 'm', baseUrl, 'k', http.fetcher).stream(REQUEST, signal()));
return dig(http.body(), 'tools', 0, 'function', 'parameters');
};
expect(await parameters('https://generativelanguage.googleapis.com/v1beta/openai')).toEqual(
toProviderSchema(TOOL.parameters, 'gemini'),
);
expect(await parameters('http://ollama:11434/v1')).toEqual(toProviderSchema(TOOL.parameters, 'custom'));
expect(await parameters('not a url')).toEqual(toProviderSchema(TOOL.parameters, 'custom'));
});
});
describe('OpenAiResponsesClient', () => {
@@ -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();
}
});
});
@@ -6,6 +6,10 @@
*/
import type { AssistantMessageRow } from '../../../../src/db/repositories/AssistantMessages.repository';
import type { AssistantToolCallRow } from '../../../../src/db/repositories/AssistantToolCalls.repository';
import type { AiChatMessage } from '../../../../src/nest/ai-connections/ai-chat-client.interface';
import { toAnthropicMessages } from '../../../../src/nest/ai-connections/providers/anthropic-messages.client';
import { toChatMessages } from '../../../../src/nest/ai-connections/providers/openai-chat.client';
import { toResponsesInput } from '../../../../src/nest/ai-connections/providers/openai-responses.client';
import {
messageIdOf,
outputText,
@@ -249,7 +253,7 @@ describe('toChatHistory', () => {
]);
});
it('ASTHIS-005d: the text of a file the user let the model read comes after the step, once, cut in earlier turns', () => {
it('ASTHIS-005d: the text of a file the user let the model read answers its read, once, cut in earlier turns', () => {
const id = '0b7c2f0e-6f6d-4a8e-9b1a-3d2c1e0f9a87';
type CallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>;
const read = (toolCallId: string, extra: Partial<CallPart>): AssistantMessagePart => ({
@@ -270,20 +274,27 @@ describe('toChatHistory', () => {
row('user', [{ type: 'text', text: 'Thanks' }]),
];
const history = toChatHistory(rows, new Map(), new Map([[id, long]]));
expect(history.map((message) => message.role)).toEqual(['user', 'assistant', 'user', 'user', 'user']);
expect(history[1]).toMatchObject({ role: 'assistant', text: 'I need to read it first.', toolCalls: [] });
const told = (history[2] as { text: string }).text;
expect(history.map((message) => message.role)).toEqual(['user', 'assistant', 'tool', 'assistant', 'tool', 'user']);
expect(history[1]).toMatchObject({
role: 'assistant',
text: 'I need to read it first.',
toolCalls: [{ id: 'r1', name: 'read_attached_file' }],
});
expect(history[2]).toMatchObject({ role: 'tool', toolCallId: 'r1' });
const told = (history[2] as { output: string }).output;
expect(told).toContain('The user let you read the attached file "AirBnB Buchung.pdf".');
expect(told).toContain('Casa Lisboa');
// An earlier turn's text is cut so it does not crowd out the conversation.
expect(told.length).toBeLessThan(long.length);
expect(told.endsWith('[...]')).toBe(true);
expect((history[3] as { text: string }).text).toBe(
'You read the attached file "AirBnB Buchung.pdf" again; its text is above.',
);
expect(history[4]).toMatchObject({
role: 'tool',
toolCallId: 'r2',
output: 'You read the attached file "AirBnB Buchung.pdf" again; its text is above.',
});
// The latest turn keeps the whole text.
const latest = toChatHistory(rows.slice(0, 2), new Map(), new Map([[id, long]]));
expect((latest[2] as { text: string }).text).toContain(long);
expect((latest[2] as { output: string }).output).toContain(long);
});
it('ASTHIS-005e: a read the user did not allow, one still waiting, a refused one and a swept file say so', () => {
@@ -303,21 +314,21 @@ describe('toChatHistory', () => {
]),
],
new Map(),
).map((message) => (message as { text: string }).text);
expect(step({ approval: { id: 'r', approved: false } })[1]).toBe(
).map((message) => (message.role === 'tool' ? message.output : null));
expect(step({ approval: { id: 'r', approved: false } })[2]).toBe(
'The user did not let you read the attached file "a.pdf".',
);
expect(step({ approval: { id: 'r' } })[1]).toContain("waits for the user's agreement; you have not read it.");
expect(step({ approval: { id: 'r', resolution: 'expired' } })[1]).toContain('you have not read it');
expect(step({ result: 'No such attachment in this conversation.', isError: true })[1]).toBe(
expect(step({ approval: { id: 'r' } })[2]).toContain("waits for the user's agreement; you have not read it.");
expect(step({ approval: { id: 'r', resolution: 'expired' } })[2]).toContain('you have not read it');
expect(step({ result: 'No such attachment in this conversation.', isError: true })[2]).toBe(
'Reading the attached file "a.pdf" failed: No such attachment in this conversation.',
);
expect(step({ result: { read: true } })[1]).toBe(
expect(step({ result: { read: true } })[2]).toBe(
'The attached file "a.pdf" is no longer kept; you cannot read it again.',
);
});
it('ASTHIS-005f: keeping an attached file is told after its step, whatever became of it', () => {
it('ASTHIS-005f: keeping an attached file is answered with what became of it', () => {
const keep = (extra: Partial<Extract<AssistantMessagePart, { type: 'tool-call' }>>) =>
toChatHistory(
[
@@ -336,11 +347,12 @@ describe('toChatHistory', () => {
new Map(),
);
const kept = keep({ approval: { id: 'k', approved: true }, result: { file: { id: 31 } } });
expect(kept.map((message) => message.role)).toEqual(['user', 'assistant', 'user']);
expect((kept[2] as { text: string }).text).toBe(
'The attached file "a.pdf" is in the trip\'s Files now (file id 31).',
);
const lastOf = (extra: Parameters<typeof keep>[0]) => (keep(extra).at(-1) as { text: string }).text;
expect(kept.map((message) => message.role)).toEqual(['user', 'assistant', 'tool']);
expect(kept[2]).toMatchObject({
toolCallId: 'k',
output: 'The attached file "a.pdf" is in the trip\'s Files now (file id 31).',
});
const lastOf = (extra: Parameters<typeof keep>[0]) => (keep(extra).at(-1) as { output: string }).output;
expect(
lastOf({ approval: { id: 'k', approved: true }, result: 'The file could not be kept.', isError: true }),
).toBe('The attached file "a.pdf" could not be kept in Files: The file could not be kept.');
@@ -448,6 +460,168 @@ describe('toChatHistory', () => {
});
});
describe('toChatHistory: calls the model made of the server tools (#2845)', () => {
/**
* The thread of #2845: a step that kept a trip note on the way (it runs at
* once), then a step whose changes the user applied, both with the
* provider's own record of the step stored beside them.
*/
function appliedAfterANote(
noteState: Record<string, unknown>,
changeState: Record<string, unknown>,
): AiChatMessage[] {
const rows = [
row('user', [{ type: 'text', text: 'Dinner at 20:00 on day 2, and remember we like late dinners' }]),
row(
'assistant',
[
{
type: 'tool-call',
toolCallId: 'call_note',
toolName: 'remember_for_trip',
args: { note: 'Likes late dinners' },
result: { notes: 'Likes late dinners' },
},
],
{ provider_state: JSON.stringify(noteState) },
),
row(
'assistant',
[
{ type: 'text', text: 'Shall I move it?' },
{
type: 'tool-call',
toolCallId: 'held-1',
toolName: 'update_assignment_time',
args: { assignmentId: 7, place_time: '20:00' },
result: { assignment: { id: 7 } },
approval: { id: 'held-1', approved: true },
},
],
{ provider_state: JSON.stringify(changeState) },
),
];
return toChatHistory(rows, new Map([['held-1', 'call_move']]));
}
it('ASTHIS-015: the Responses API gets an answer for every call it made, a kept note included', () => {
const history = appliedAfterANote(
{
adapter: 'openai-responses',
items: [
{ type: 'reasoning', id: 'rs_1', encrypted_content: 'x' },
{ type: 'function_call', call_id: 'call_note', name: 'remember_for_trip', arguments: '{}' },
],
},
{
adapter: 'openai-responses',
items: [
{ type: 'reasoning', id: 'rs_2', encrypted_content: 'y' },
{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Shall I move it?' }] },
{ type: 'function_call', call_id: 'call_move', name: 'update_assignment_time', arguments: '{}' },
],
},
);
const input = toResponsesInput({ system: '', messages: history, tools: [] });
const made = input.filter((item) => item.type === 'function_call').map((item) => item.call_id);
const answered = input.filter((item) => item.type === 'function_call_output').map((item) => item.call_id);
expect(made).toEqual(['call_note', 'call_move']);
expect(answered).toEqual(['call_note', 'call_move']);
for (const id of made) {
const call = input.findIndex((item) => item.type === 'function_call' && item.call_id === id);
const output = input.findIndex((item) => item.type === 'function_call_output' && item.call_id === id);
expect(output).toBeGreaterThan(call);
}
expect(input.find((item) => item.type === 'function_call_output' && item.call_id === 'call_note')).toMatchObject({
output: '{"notes":"Likes late dinners"}',
});
// Nothing empty goes out as the user's: a kept note is no step of the server to tell.
const userTexts = input
.filter((item) => item.type === 'message' && item.role === 'user')
.flatMap((item) => (item.content as Array<{ text?: string }>).map((part) => part.text ?? ''));
expect(userTexts).toEqual(['Dinner at 20:00 on day 2, and remember we like late dinners']);
});
it('ASTHIS-016: Anthropic gets a tool_result for every tool_use it replays, a kept note included', () => {
const history = appliedAfterANote(
{
adapter: 'anthropic-messages',
blocks: [
{ type: 'thinking', thinking: 'note it', signature: 's1' },
{ type: 'tool_use', id: 'call_note', name: 'remember_for_trip', input: {} },
],
},
{
adapter: 'anthropic-messages',
blocks: [
{ type: 'thinking', thinking: 'move it', signature: 's2' },
{ type: 'text', text: 'Shall I move it?' },
{ type: 'tool_use', id: 'call_move', name: 'update_assignment_time', input: {} },
],
},
);
const messages = toAnthropicMessages(history);
const uses = messages.flatMap((message) => message.content).filter((block) => block.type === 'tool_use');
expect(uses.map((block) => block.id)).toEqual(['call_note', 'call_move']);
messages.forEach((message, index) => {
const ids = message.content.filter((block) => block.type === 'tool_use').map((block) => block.id);
if (ids.length === 0) return;
const next = messages[index + 1];
expect(next?.role).toBe('user');
const results = next.content.filter((block) => block.type === 'tool_result').map((block) => block.tool_use_id);
expect(results).toEqual(ids);
});
expect(messages.flatMap((message) => message.content).some((block) => block.text === '')).toBe(false);
});
it('ASTHIS-018: Gemini gets every stored call back with its thought signature, an applied change included', () => {
const state = (id: string, signature: string) => ({
adapter: 'openai-chat',
model: 'gemini-3-flash',
signatures: { [id]: signature },
});
type SentCall = { id: string; extra_content?: { google: { thought_signature: string } } };
const history = appliedAfterANote(state('call_note', 'sig-note'), state('call_move', 'sig-move'));
const sent = toChatMessages({ system: '', messages: history, tools: [] }, 'gemini-3-flash')
.flatMap((message) => (message.tool_calls ?? []) as SentCall[])
.map((call) => [call.id, call.extra_content?.google.thought_signature]);
expect(sent).toEqual([
['call_note', 'sig-note'],
['call_move', 'sig-move'],
]);
});
it('ASTHIS-017: a step that only kept a note is the model call it was, answered with what was kept', () => {
const history = toChatHistory(
[
row('user', [{ type: 'text', text: 'Remember the ferry leaves at 7' }]),
row('assistant', [
{
type: 'tool-call',
toolCallId: 'n1',
toolName: 'remember_for_trip',
args: { note: 'Ferry at 7' },
result: { notes: 'Ferry at 7' },
},
]),
row('assistant', [{ type: 'text', text: 'Noted.' }]),
],
new Map(),
);
expect(history).toEqual([
{ role: 'user', text: 'Remember the ferry leaves at 7' },
{
role: 'assistant',
text: '',
toolCalls: [{ id: 'n1', name: 'remember_for_trip', args: { note: 'Ferry at 7' } }],
providerState: null,
},
{ role: 'tool', toolCallId: 'n1', name: 'remember_for_trip', output: '{"notes":"Ferry at 7"}' },
{ role: 'assistant', text: 'Noted.', toolCalls: [], providerState: null },
]);
});
});
describe('toThreadMessages', () => {
it('ASTHIS-010: the steps of one answer are one message under the first row id', () => {
nextId = 10;
@@ -142,7 +142,7 @@ describe('describeToolCall', () => {
});
expect(describe_('update_day_note', { dayId: 31, noteId: 41, time: null })).toMatchObject({
subject: 'Rail pass',
rows: [{ op: 'change', labelKey: 'time', before: '09:00', after: '' }],
rows: [{ op: 'change', labelKey: 'content', before: '09:00', after: '' }],
});
expect(describe_('create_day_note', { dayId: 33, text: 'Laundry', icon: '🧺' }).rows).toEqual([
{ op: 'add', labelKey: 'day', before: null, after: 'Spare day' },
@@ -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');
});
@@ -19,7 +19,6 @@ import {
bookPhotoElementSchema,
bookShapeElementSchema,
bookTextElementSchema,
MAX_SPREADS,
type BookDesignPlanRow,
type BookDocument,
type BookSpread,
@@ -310,15 +309,15 @@ describe('applyDesignDraft', () => {
expect(out.doc.spreads).toEqual(base.spreads);
});
it('JBAPPLY-014: a book at the page limit takes no new page, the page named as changed since', () => {
const inner = Array.from({ length: MAX_SPREADS - 1 }, (_, i) => page(`i${i}`, 'inner'));
const full = book([page('c', 'cover'), ...inner]);
const draft: DraftToApply = { base: full, doc: book(pages('n')), plan: [row('n', 'insert')], selection: ['n'] };
it('JBAPPLY-014: a book of hundreds of pages still takes a new page', () => {
const inner = Array.from({ length: 400 }, (_, i) => page(`i${i}`, 'inner'));
const long = book([page('c', 'cover'), ...inner]);
const draft: DraftToApply = { base: long, doc: book(pages('n')), plan: [row('n', 'insert')], selection: ['n'] };
const out = applyDesignDraft(full, draft, new Set());
const out = applyDesignDraft(long, draft, new Set());
expect(out.skipped).toEqual([{ spreadId: 'n', reason: 'changed_since' }]);
expect(out.doc.spreads).toHaveLength(MAX_SPREADS);
expect([out.applied, out.skipped]).toEqual([['n'], []]);
expect(out.doc.spreads).toHaveLength(402);
});
it('JBAPPLY-015: a page whose letters are filled with a photo gone since stays out, as a frame of it would', () => {
@@ -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,61 @@ 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, routeGeometry: '[[1,2],[3,4]]' }, 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.tracks ?? [])]).toEqual(['lake\u0000']);
});
it('keeps two tracks of one name apart when they run differently (#2870)', () => {
// Two watch hikes, both called "Afternoon Hike": one up the valley, one along the coast.
const valley = '[[46.5,8.0,1200],[46.52,8.03,1500],[46.55,8.06,1900]]';
const coast = '[[43.7,7.25],[43.72,7.3],[43.74,7.35]]';
const dedup = dedupSetOf([{ name: 'Afternoon Hike', lat: 46.5, lng: 8.0, route_geometry: valley }]);
expect(
isPlaceDuplicate({ name: 'afternoon hike', lat: 43.7, lng: 7.25, track: true, routeGeometry: coast }, dedup),
).toBe(false);
// The same file again is still the same track, its points rounded or thinned as they may be.
const valleyAgain = '[[46.50001,8.00002],[46.55,8.06]]';
expect(
isPlaceDuplicate({ name: 'Afternoon Hike', lat: 46.5, lng: 8.0, track: true, routeGeometry: valleyAgain }, dedup),
).toBe(true);
// Within one file: the first of the two goes in, the second is not taken for it.
const batch = emptyDedup();
const first = { name: 'Day 1', lat: 46.5, lng: 8.0, track: true, routeGeometry: valley };
const second = { name: 'Day 1', lat: 43.7, lng: 7.25, track: true, routeGeometry: coast };
expect(isPlaceDuplicate(first, batch)).toBe(false);
trackInsertedInDedupSet(first, batch);
expect(isPlaceDuplicate(second, batch)).toBe(false);
trackInsertedInDedupSet(second, batch);
expect(isPlaceDuplicate({ ...second }, batch)).toBe(true);
// A line that does not parse falls back to the name alone, on both sides.
const broken = dedupSetOf([{ name: 'Loop', lat: 1, lng: 2, route_geometry: 'not json' }]);
expect(isPlaceDuplicate({ name: 'Loop', lat: 1, lng: 2, track: true, routeGeometry: '{}' }, broken)).toBe(true);
expect(isPlaceDuplicate({ name: 'Loop', lat: 1, lng: 2, track: true, routeGeometry: coast }, broken)).toBe(false);
});
it('matches a named place case- and whitespace-insensitively', () => {
const dedup = emptyDedup();
trackInsertedInDedupSet({ name: ' Eiffel Tower ', lat: 1, lng: 2 }, dedup);
@@ -6,10 +6,12 @@
* line is written as GPX.
*/
import { PlaceImportService } from '../../../src/nest/place-import/place-import.service';
import { PlacesService } from '../../../src/nest/places/places.service';
import { TourFileReadService } from '../../../src/nest/tours/tour-file-read.service';
import { gpxOfLine, splitGpxTracks } from '../../../src/nest/tours/tour-file-split.helpers';
import { TourFilesModule } from '../../../src/nest/tours/tour-files.module';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makePlace } from '../../helpers/factories/places';
import { makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { createTestModule, type TestModule } from '../../helpers/test-module';
@@ -18,7 +20,7 @@ import { TOUR_GPX_TEXT_MAX } from '@trek/shared';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
// TOUR-FILE-READ-001 to TOUR-FILE-READ-008
// TOUR-FILE-READ-001 to TOUR-FILE-READ-009
const db = createSnapshotTestDb();
let t: TestModule;
@@ -130,6 +132,25 @@ describe('TourFileReadService', () => {
});
});
it('TOUR-FILE-READ-009: a track from before Tours under the same name is proposed, for the import to take over (#2810)', async () => {
const trip = await tripId();
// 4.x stored a GPX track as a plain place: a line, no tours row.
await makePlace(t.get(MikroORM), trip, {
name: 'Saturday',
lat: 47.5,
lng: 11.1,
route_geometry: '[[47.5,11.1,800],[47.51,11.1,1000]]',
});
const rows = t.get(PlaceImportService).readGpx(Buffer.from(TWO_TRACKS, 'utf8'), { importTracks: true });
expect((await t.get(PlacesService).gpxDuplicates(String(trip), rows)).some(Boolean)).toBe(true);
const read = await files.read(trip, Buffer.from(TWO_TRACKS, 'utf8'), 'weekend.gpx');
expect(read.tracks.map((track) => [track.name, track.duplicate])).toEqual([
['Saturday', false],
['Sunday', false],
]);
});
it('TOUR-FILE-READ-008: a track longer than one import takes is named but carries no text', async () => {
const points = Array.from(
{ length: 20_000 },
@@ -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;`);
@@ -403,9 +418,10 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
});
it('imports only the new tracks and publishes only committed rows in a mixed result', async () => {
// The same Ridge as in the mixed file, line and all: a track is a duplicate only of one that runs alike (#2870).
const existing = Buffer.from(`<gpx><trk><name>Ridge</name><trkseg>
<trkpt lat="49" lon="12"><ele>100</ele></trkpt><trkpt lat="49.01" lon="12.01"><ele>150</ele></trkpt>
</trkseg></trk></gpx>`);
</trkseg><trkseg><trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt></trkseg></trk></gpx>`);
expect((await tours.importGpxAsTour(tripId, existing))?.tours).toHaveLength(1);
broadcast.mockClear();
@@ -311,7 +311,7 @@ describe('buildTransitReservationParts', () => {
],
};
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary);
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary, 'transitous');
expect(endpoints).toEqual([
{
@@ -407,7 +407,7 @@ describe('buildTransitReservationParts', () => {
const kyoto = { name: 'Kyoto', lat: 35.0116, lng: 135.7681 };
const itinerary = twoLegs();
itinerary.legs = [{ ...itinerary.legs[0], to: stop('Umeda', 34.702, 135.496, null) }, itinerary.legs[1]];
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary);
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary, 'transitous');
expect(endpoints[1]).toEqual({
role: 'stop',
sequence: 1,
@@ -423,14 +423,18 @@ describe('buildTransitReservationParts', () => {
});
it('TRANSIT-ITIN-020: an unresolvable endpoint timezone throws with the coordinates in the message', () => {
expect(() => buildTransitReservationParts({ name: 'Nowhere', lat: NaN, lng: 135.501 }, umeda, singleLeg())).toThrow(
'Unable to resolve timezone for NaN,135.501.',
);
expect(() =>
buildTransitReservationParts({ name: 'Nowhere', lat: NaN, lng: 135.501 }, umeda, singleLeg(), 'transitous'),
).toThrow('Unable to resolve timezone for NaN,135.501.');
});
it('TRANSIT-ITIN-021: an unconvertible itinerary time throws with the ISO string and timezone', () => {
expect(() => buildTransitReservationParts(namba, umeda, singleLeg({ startTime: 'not-a-date' }))).toThrow(
'Unable to convert not-a-date to local time in Asia/Tokyo.',
);
expect(() =>
buildTransitReservationParts(namba, umeda, singleLeg({ startTime: 'not-a-date' }), 'transitous'),
).toThrow('Unable to convert not-a-date to local time in Asia/Tokyo.');
});
it('TRANSIT-ITIN-022: the journey records the backend its itinerary came from (#2850)', () => {
expect(buildTransitReservationParts(namba, umeda, singleLeg(), 'google').metadata.transit.provider).toBe('google');
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/shared",
"version": "5.0.0",
"version": "5.0.1",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
+3
View File
@@ -154,6 +154,9 @@ export const ASSISTANT_CHANGE_LABEL_KEYS = [
'currency',
'stopType',
'text',
// A day note's heading and its body; `text` and `time` stay for rows already stored.
'title',
'content',
'icon',
'color',
'position',
+4 -8
View File
@@ -5,7 +5,7 @@ import {
bookPageOpSchema,
bookSpreadIdSchema,
} from '../../book/book-ops.schema';
import { bookSpreadSchema, MAX_BOOK_TITLE, MAX_SPREADS } from '../../book/book.schema';
import { bookSpreadSchema, MAX_BOOK_TITLE } from '../../book/book.schema';
import { idSchema } from '../../common/primitives.schema';
import { cardCountSchema, cardText } from './card-parts';
@@ -33,14 +33,10 @@ export const BOOK_PAGE_CARD_CHANGES = ['new', 'changed', 'moved', 'willDelete']
export const bookCardPageSchema = z.object({
spreadId: bookSpreadIdSchema,
/** Where it stands in the book, the cover at 0. */
index: z
.number()
.int()
.min(0)
.max(MAX_SPREADS - 1),
index: z.number().int().min(0),
role: bookSpreadSchema.shape.role.unwrap(),
/** Its place among the double pages, from 1; null for a single page (the covers, the first and the last). */
number: z.number().int().min(1).max(MAX_SPREADS).nullable(),
number: z.number().int().min(1).nullable(),
/** Photos placed on it. */
photos: cardCountSchema,
});
@@ -85,7 +81,7 @@ export const bookPageCardSchema = z.object({
/** How many pages the change adds or deletes; for a new book, its pages. */
count: cardCountSchema,
/** Where a moved page lands among the double pages, from 1. */
movedTo: z.number().int().min(1).max(MAX_SPREADS).nullable(),
movedTo: z.number().int().min(1).nullable(),
/**
* `edit_book_page`'s operations, to draw the page after where the card has
* no `after` or the Studio's book moved on from `baseVersion`; empty for
+2 -2
View File
@@ -1,6 +1,6 @@
import { bookElementId } from '../book-ids';
import { blankBook, blankSpread } from '../book-pages';
import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
import { MAX_BOOK_TITLE, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
import type { JourneyStats } from '../journey-stats.schema';
import {
STATIONS_PER_PAGE,
@@ -155,7 +155,7 @@ export function buildBook(input: AutoInput): BookDocument {
version: 1,
title: input.title.slice(0, MAX_BOOK_TITLE),
page: withFirstPage(input.page),
spreads: spreads.slice(0, MAX_SPREADS),
spreads,
};
}

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