Compare commits

...

38 Commits

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

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

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

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

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

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

AI parsing through an own server always called Ollama's /api/chat, which an
OpenAI-compatible server such as Groq answers with 404. A server without it
now gets the same extraction over /chat/completions at the address as
entered (#2806).
2026-10-26 10:20:46 +01:00
Maurice 8ba7add541 fix(plugins): give the runtime test's plugins table the accepted-range columns, and move the theme entry with the menu code 2026-10-26 09:49:22 +01:00
Maurice a3664e7117 fix(plugins): lower the unused-key entry the moved components reach again, and test the marked install 2026-10-26 09:49:22 +01:00
Maurice 64ed2175a5 fix(plugins): keep the API client and the panel test within their size entries, drop an unused import 2026-10-26 09:49:22 +01:00
Maurice 447ae54b93 fix(i18n): translate the plugin filter button where it read as English 2026-10-26 09:49:22 +01:00
Maurice 0067b0ff19 fix(plugins): let an admin run a plugin outside its TREK range, and tidy the plugins panel
TREK 5.0 left most registry plugins blocked because their authors still declare
"<5.0.0", and the only way past that was TREK_PLUGINS_IGNORE_TREK_RANGE for every
plugin at once. An admin can now install or switch on one such plugin after a
warning; the consent is kept on its row for that plugin version and TREK major,
so an update or a new major asks again. The environment switch works as before.

Discover marks a plugin not confirmed for this TREK on its card, and Install opens
the warning first. Admin > Plugins reads cleaner: one row of alike facts per plugin
with each state that needs attention on its own line, type, status and order behind
one Filter button, and the containment notes in a dialog behind the shield in the
header. The row, the filter menu and the dialog moved into their own files.
2026-10-26 09:49:22 +01:00
Maurice d589da59f2 fix(wanderer): find the author of an account whose one actor wanderer expands as an object
PocketBase answers a back-relation with a single record as the object itself,
not a list, so the user lookup failed as an answer TREK did not understand and
every search with it.

Closes #2799
2026-10-26 01:03:35 +01:00
Allan Braun 69ff295184 fix(assistant): answer Brazilian Portuguese users in Portuguese, not Breton
TREK's code for Brazilian Portuguese is 'br', which Intl reads as Breton.
The assistant passed it straight to Intl.DisplayNames for the system
prompt's answer language and to the card formatters, so the model was told
to answer in Breton. Convert TREK codes to their BCP-47 tag with
getIntlLanguage before handing them to Intl.

Closes #2800
2026-10-26 00:04:56 +01:00
Maurice d07c17ded6 fix(assistant): finish an OpenRouter sign-in whose code came back after a second question mark
The return address already carries a query, so a code appended with ? instead
of & ended up inside the ai marker and the page did nothing, without a word.
2026-10-25 19:36:00 +01:00
Maurice 44509723fd fix(bookings): keep the review's map status within the complexity and strict-null budgets
The status hides itself for a stay linked to a trip place instead of each form
branching around it, and a lookup takes a missing name.
2026-10-25 18:59:55 +01:00
Maurice b80fdf27f2 fix(bookings): show where an imported stay lands on the map before it is saved
An imported hotel was looked up once, by its name and its address together.
A booking site's romanized address (Agoda for a stay in Japan) often matches
nothing and sank the hotel's name with it, so the place was created without
coordinates and silently never showed on the map.

The review now finds the stay right away (name and address, then the name,
then the address) and says under the address whether it is on the map and
where, or that it is not yet, with a way to search again. Picking an address
suggestion sets the spot by hand. The save uses that spot, falls back the same
way when there is none, and a stay saved without a spot says so in a toast.
Desktop dialog and phone sheet share the lookup through useReservationForm.
2026-10-25 18:59:55 +01:00
Maurice a0b7b4d196 chore(lint): lower the complexity baseline the route store move took down 2026-10-25 18:59:49 +01:00
Maurice aa24a9eff7 fix(map): keep routed roads on the device so a whole-trip route opens drawn
With "Show whole trip" on, every leg of every day went to the router one at a
time, a second apart, and the route cache lived in memory only, so each visit
started over: a trip of a month took 30 to 45 seconds before its route was
drawn, on every open.

Routes the router answered are now also kept on the device (Dexie v14,
routeCache by RouteCalculator's cache key, a month at most, the oldest give way
past 3000). A leg found there needs no request and no pause, so a trip whose
legs did not change opens with its roads at once; only new or changed legs are
routed. Plugin routes stay in memory only, since they depend on trip and day.
The cache moves out of RouteCalculator into routeStore.ts.

Closes #2794
2026-10-25 18:59:49 +01:00
Maurice de5417f9f0 style(i18n): wrap the wanderer no_author line in the CJK locales 2026-10-25 18:59:43 +01:00
Maurice a48dd7645f fix(wanderer): say so when wanderer names no author for the token's account
A token whose account has no local ActivityPub author was reported as
invalid_response, which the settings show as 'The answer did not come from
wanderer', although wanderer had answered fine. It now has its own code,
no_author, with a message in every language that says what is missing.
2026-10-25 18:59:43 +01:00
Maurice 217a576e16 fix(wanderer): log why an answer from wanderer was not understood
A failed wanderer call kept its reason in WandererError.detail (the field that
did not match, or what came instead of JSON), and the client comment said it
stays in the log, but nothing wrote it anywhere. The log only had the generic
'wanderer answered with something TREK does not understand', so neither an
admin nor we could tell what wanderer had sent. Every refusal now logs its
code, message and detail.
2026-10-25 18:59:43 +01:00
Maurice f6976d5cec fix(assistant): send OpenAI reasoning models on an API key to the Responses API
gpt-5.6-sol, gpt-6.1-sol and the other reasoning models refuse function tools
on Chat Completions, so every assistant message on such a model came back as
HTTP 400. An OpenAI connection whose model is from the o-series or GPT-5 and
later now goes through the Responses API, the client a ChatGPT plan already
uses, with the key as its bearer token and no token refresh on a 401. Older
models and the -chat variants stay on Chat Completions.

Closes #2792
2026-10-25 16:55:39 +01:00
Maurice c1c2bbf146 fix(assistant): replay an Anthropic answer that thought with its thinking blocks
Sonnet 5.5 and Opus 5.5 think by default and write what they say between tool
calls into thinking blocks, which they want back unchanged with the rest of
the turn. The client dropped those blocks (and their signatures) and rebuilt
the turn from its text and calls, so after a tool result the next step came
back empty and the assistant gave up with empty_answer. A turn that thought
is now replayed block by block like one that searched the web; the cache mark
before the question skips a thinking block, and replayed blocks are copies so
a mark never sticks to the stored answer.

Closes #2789
2026-10-25 16:49:46 +01:00
Maurice c05349f7c4 fix(docker): let dumb-init forward the stop signal in the shipped compose file
The compose file drops every capability and adds back CHOWN, SETUID and
SETGID. dumb-init runs as root and node as the node user, and signalling a
process of another user needs KILL, so docker stop never reached node and
always ended in SIGKILL (exit 137). KILL only reaches processes inside the
container's own PID namespace. CI starts the image with the same set and now
checks that docker stop exits 0 after a logged SIGTERM.

Closes #2790
2026-10-25 16:47:22 +01:00
Maurice a5342df648 fix(assistant): list the admin model switch among the routes a managed install withholds
The boot check pins every @ManagedForbidden route; the new
AiAdminController.setModel was missing, so the server refused to start.
2026-10-25 16:29:31 +01:00
Maurice f44b143371 fix(assistant): send a pasted ChatGPT callback once, and only for this sign-in
The server takes a sign-in only once. The clipboard read on return and a paste
of one's own could send the same address twice within one render, and an
address left over from an earlier attempt used up the one in progress, so the
real address came back as 'took too long'.
2026-10-25 16:29:31 +01:00
Maurice d911122a37 fix(assistant): let an admin switch the model of a shared ChatGPT plan
The model picker in Admin > Addons did nothing for a ChatGPT connection: the
only way to change the instance's model was saving the connection again, which
needs an API key a plan does not have. A PATCH /api/admin/ai/connection/model
now switches it on its own, to a model the provider offers.

Closes #2783
2026-10-25 16:29:31 +01:00
Maurice a69eee191c test(plugins): let the instance-settings fixture run on TREK 5
The fixture plugin declared trek >=4.0.0 <5.0.0, so since the version bump to
5.0.0 the host refuses to spawn it and INS-012 fails on dev.
2026-10-25 15:59:59 +01:00
Maurice 90f31d3f07 fix(assistant): give the phone home chat its own m-root so it is not see-through
The home chat outside a trip mounts beside MobileShell, so none of the --m-*
tokens it paints with resolved and the dashboard showed through the chat.
2026-10-25 15:59:59 +01:00
Maurice d4da05bbc9 docs(readme): name the container and allow plain-HTTP login in the quickstart
docker logs trek found nothing because the run command gave no --name, and
over plain HTTP the browser dropped the Secure session cookie, so the printed
admin login did not stick.
2026-10-25 15:41:45 +01:00
295 changed files with 4227 additions and 867 deletions
+12 -1
View File
@@ -1161,7 +1161,7 @@ jobs:
docker run -d --name trek-ci -p 127.0.0.1:3000:3000 \
--read-only --tmpfs /tmp:noexec,nosuid,size=128m \
--security-opt no-new-privileges:true \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add KILL \
-v "$RUNNER_TEMP/trek/data:/app/data" \
-v "$RUNNER_TEMP/trek/uploads:/app/uploads" \
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
@@ -1194,6 +1194,17 @@ jobs:
curl -sf http://127.0.0.1:3000/ | grep -q '<div id="root">' \
|| { echo "::error::/ does not serve the client"; exit 1; }
# Same capabilities as the shipped compose file: dumb-init has to be able
# to pass the stop signal on to node, or docker stop ends in SIGKILL (137).
- name: Stop gracefully
run: |
set -euo pipefail
docker stop -t 30 trek-ci >/dev/null
code="$(docker inspect -f '{{.State.ExitCode}}' trek-ci)"
[ "$code" = "0" ] || { echo "::error::docker stop ended with exit code $code, not a graceful shutdown"; exit 1; }
docker logs trek-ci 2>&1 | grep -q 'SIGTERM received' \
|| { echo "::error::the server never saw SIGTERM"; exit 1; }
- name: Container log
if: always()
run: docker logs trek-ci 2>&1 || true
+5 -2
View File
@@ -234,12 +234,15 @@ the details.
## Get started in 30 seconds
```bash
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d --name trek -p 3000:3000 \
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
-e COOKIE_SECURE=false \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
<div align="center">
+2 -2
View File
@@ -1,9 +1,9 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 382,
"@typescript-eslint/no-misused-promises": 849,
"@typescript-eslint/no-misused-promises": 848,
"no-console": 88,
"complexity": 221,
"complexity": 220,
"@typescript-eslint/no-explicit-any": 146,
"trek/no-swallowed-catch": 130,
"max-params": 32,
+1 -1
View File
@@ -1,5 +1,5 @@
{
"admin.ts": 126,
"admin.ts": 112,
"atlas.ts": 9,
"backup.ts": 10,
"budget.ts": 36,
-1
View File
@@ -150,7 +150,6 @@
"src/pages/resetPassword/useResetPassword.ts",
"src/pages/settings/useSettings.ts",
"src/pages/sharedTrip/useSharedTrip.ts",
"src/pages/tripPlanner/useBookingEdits.ts",
"src/pages/tripPlanner/usePlaceFormOpeners.ts",
"src/pages/tripPlanner/usePlannerDialogs.ts",
"src/pages/tripPlanner/useTripPlanner.ts",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"components/Admin/AdminPluginsPanel.tsx": 2,
"components/Admin/plugins/pluginMenu.ts": 2,
"components/Budget/BudgetPanelMemberChips.tsx": 6,
"components/Budget/CostsPanel.tsx": 3,
"components/Budget/ReceiptPreviewModal.tsx": 2,
+2 -2
View File
@@ -2,13 +2,13 @@
"e2e/help/fixtures.ts": 2291,
"src/api/client.ts": 1873,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
"src/components/Budget/CostsPanel.test.tsx": 2736,
"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/RouteCalculator.ts": 1076,
"src/components/Map/RouteCalculator.ts": 1063,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"App.tsx": 11,
"components/Admin/AdminPluginsPanel.tsx": 2,
"components/Admin/DefaultUserSettingsTab.tsx": 1,
"components/Admin/plugins/pluginMenu.ts": 2,
"components/Atlas/AtlasCountryPlaces.tsx": 6,
"components/BackgroundTasks/BackgroundTasksWidget.tsx": 4,
"components/Budget/BudgetPanel.helpers.ts": 2,
+2
View File
@@ -229,6 +229,8 @@ export const aiAdminApi = {
connection: (): Promise<AiAdminConnectionResponse> => apiClient.get('/admin/ai/connection').then((r) => r.data),
saveConnection: (body: AiInstanceConnectionRequest): Promise<AiAdminConnectionResponse> =>
apiClient.put('/admin/ai/connection', body, viaProvider).then((r) => r.data),
setModel: (model: string): Promise<AiAdminConnectionResponse> =>
apiClient.patch('/admin/ai/connection/model', { model }).then((r) => r.data),
removeConnection: (): Promise<SuccessResponse> => apiClient.delete('/admin/ai/connection').then((r) => r.data),
models: (): Promise<AiModelListResponse> => apiClient.get('/admin/ai/models', viaProvider).then((r) => r.data),
};
+1
View File
@@ -692,6 +692,7 @@ describe('client > request payloads', () => {
it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => {
expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({})
expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true })
expect((await traceOne(() => adminApi.pluginActivate('koffi', false, true))).body).toEqual({ acceptTrekRange: true })
})
it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => {
+2 -2
View File
@@ -574,9 +574,9 @@ export const adminApi = {
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
pluginInstall: (id: string, opts?: Pick<PluginInstallRequest, 'version' | 'constraint' | 'withDependencies'>) =>
pluginInstall: (id: string, opts?: Omit<PluginInstallRequest, 'id'>) =>
apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data),
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
pluginActivate: (id: string, consent?: boolean, accept?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, { consent: consent || undefined, acceptTrekRange: accept || undefined }).then(r => r.data),
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
// `version` pins the exact version to install (the rollback path); omitted, the server
// resolves the newest TREK-compatible version itself.
@@ -489,10 +489,10 @@ describe('AdminPluginsPanel — TREK-version compatibility', () => {
fireEvent.click(await screen.findByText('Discover'))
}
it('blocks Install when no published version runs on this TREK, and says why', async () => {
it('marks a plugin no published version runs on, and still lets the admin install it', async () => {
await openDiscover({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
const btn = await screen.findByRole('button', { name: /^incompatible$/i })
expect(btn).toBeDisabled()
expect(await screen.findByText('Not confirmed for TREK 3.3.0 yet')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^install$/i })).toBeEnabled()
})
it('offers the newest version that DOES run here rather than a dead button', async () => {
@@ -549,9 +549,11 @@ function panelRoot(container: HTMLElement): HTMLElement {
}
/** Opens one of the toolbar dropdowns (identified by its current title) and picks an option. */
async function pickFilter(trigger: RegExp, option: RegExp) {
fireEvent.click(await screen.findByTitle(trigger))
fireEvent.click(await screen.findByRole('button', { name: option }))
/** The toolbar's one Filter button; a pick in its menu leaves the menu open. */
const filterButton = (): HTMLElement => screen.getByRole('button', { name: /^Filter/ })
async function pickFilter(option: RegExp) {
if ((await screen.findByRole('button', { name: /^Filter/ })).getAttribute('aria-expanded') !== 'true') fireEvent.click(filterButton())
fireEvent.click(await screen.findByRole('menuitemradio', { name: option }))
}
/** The enable toggle of a named installed row. */
@@ -610,9 +612,9 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
await pickFilter(/^Type: All types$/, /^Widget$/)
await pickFilter(/^Widget$/)
expect(await screen.findByTitle('Type: Widget')).toBeInTheDocument()
expect(await screen.findByRole('menuitemradio', { name: 'Widget' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByText('Countdown')).toBeInTheDocument()
expect(screen.queryByText('Gotify')).not.toBeInTheDocument()
})
@@ -626,15 +628,15 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
render(<AdminPluginsPanel />)
await screen.findByText('OnOne')
await pickFilter(/^Status: All$/, /^Off$/)
await pickFilter(/^Off$/)
expect(await screen.findByText('OffOne')).toBeInTheDocument()
expect(screen.queryByText('OnOne')).not.toBeInTheDocument()
await pickFilter(/^Status: Off$/, /^Error$/)
await pickFilter(/^Error$/)
expect(await screen.findByText('BadOne')).toBeInTheDocument()
expect(screen.getByText('boom')).toBeInTheDocument()
await pickFilter(/^Status: Error$/, /^Update available$/)
await pickFilter(/^Update available$/)
expect(await screen.findByText('OnOne')).toBeInTheDocument()
expect(screen.queryByText('BadOne')).not.toBeInTheDocument()
})
@@ -644,11 +646,10 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Gotify')
await pickFilter(/^Sort: Name$/, /^Updates first$/)
expect(await screen.findByTitle('Sort: Updates first')).toBeInTheDocument()
await pickFilter(/^Updates first$/)
expect(filterButton()).toHaveTextContent('Updates first')
await clickDiscover()
await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument())
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
})
it('FE-COMP-PLUGINS-PANEL-008: rescan re-pulls the registry past its cache and confirms with a toast', async () => {
@@ -1663,15 +1664,16 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
})
})
describe('AdminPluginsPanel — security footer', () => {
it('FE-COMP-PLUGINS-PANEL-048: the containment note expands into its sections', async () => {
describe('AdminPluginsPanel — security dialog', () => {
it('FE-COMP-PLUGINS-PANEL-048: the shield in the header opens how plugins are contained', async () => {
panelWith([])
render(<AdminPluginsPanel />)
await screen.findByText('No plugins installed yet.')
expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('How plugins are contained — and the limits'))
fireEvent.click(screen.getByRole('button', { name: 'How plugins are secured' }))
expect(screen.getByRole('dialog', { name: 'How plugins are secured' })).toBeInTheDocument()
expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument()
expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument()
expect(screen.getByText('What "Signed" means')).toBeInTheDocument()
@@ -1700,9 +1702,9 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
row({ id: 'c-page', name: 'Gamma Page', description: 'a page', type: 'page', enabled: 1, status: 'error', last_error: 'boom' }),
]
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, filter: RegExp, option: RegExp) => {
await user.click(screen.getByTitle(filter))
await user.click(screen.getByRole('button', { name: option }))
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, option: RegExp) => {
if (filterButton().getAttribute('aria-expanded') !== 'true') await user.click(filterButton())
await user.click(screen.getByRole('menuitemradio', { name: option }))
}
it('FE-W5PLG-001: the search box narrows the installed list by name and description', async () => {
@@ -1734,7 +1736,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await pickFilter(user, /^Type:/, /^Widget$/)
await pickFilter(user, /^Widget$/)
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
expect(screen.getByText('Alpha Widget')).toBeInTheDocument()
@@ -1746,15 +1748,15 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await pickFilter(user, /^Status:/, /^Active$/)
await pickFilter(user, /^Active$/)
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
expect(screen.queryByText('Gamma Page')).not.toBeInTheDocument()
await pickFilter(user, /^Status:/, /^Off$/)
await pickFilter(user, /^Off$/)
await waitFor(() => expect(screen.getByText('Beta Bridge')).toBeInTheDocument())
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
await pickFilter(user, /^Status:/, /^Error$/)
await pickFilter(user, /^Error$/)
await waitFor(() => expect(screen.getByText('Gamma Page')).toBeInTheDocument())
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
})
@@ -1766,13 +1768,13 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
await screen.findByText('Alpha Widget')
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
await pickFilter(user, /^Sort:/, /^Updates first$/)
await pickFilter(user, /^Updates first$/)
await waitFor(() => {
const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map(e => e.textContent)
expect(names[0]).toBe('Gamma Page')
})
await pickFilter(user, /^Status:/, /^Update available$/)
await pickFilter(user, /^Update available$/)
await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument())
expect(screen.getByText('Gamma Page')).toBeInTheDocument()
})
@@ -1783,11 +1785,10 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await pickFilter(user, /^Sort:/, /^Updates first$/)
expect(screen.getByTitle(/^Sort: Updates first$/)).toBeInTheDocument()
await pickFilter(user, /^Updates first$/)
expect(filterButton()).toHaveTextContent('Updates first')
await user.click(screen.getByRole('tab', { name: /Discover/ }))
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
})
it('FE-W5PLG-007: the registry sorts by downloads and by review date', async () => {
@@ -1802,19 +1803,19 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
await user.click(screen.getByRole('tab', { name: /Discover/ }))
await screen.findByText('Old Tool')
await pickFilter(user, /^Sort:/, /^Most downloads$/)
await pickFilter(user, /^Most downloads$/)
await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool')
})
await pickFilter(user, /^Sort:/, /^Recently updated$/)
await pickFilter(user, /^Recently updated$/)
await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool')
})
await pickFilter(user, /^Sort:/, /^Name$/)
await pickFilter(user, /^Name$/)
await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool')
@@ -1833,7 +1834,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
await user.click(screen.getByRole('tab', { name: /Discover/ }))
await screen.findByText('Mapper')
await pickFilter(user, /^Type:/, /^Page$/)
await pickFilter(user, /^Page$/)
await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument())
expect(screen.getByText('Poster')).toBeInTheDocument()
@@ -1962,12 +1963,12 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
const { container } = render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget')
await user.click(screen.getByTitle(/^Type:/))
expect(screen.getByRole('button', { name: /^Widget$/ })).toBeInTheDocument()
await user.click(filterButton())
expect(screen.getByRole('menuitemradio', { name: /^Widget$/ })).toBeInTheDocument()
const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement
fireEvent.click(layer)
await waitFor(() => expect(screen.queryByRole('button', { name: /^Widget$/ })).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByRole('menuitemradio', { name: /^Widget$/ })).not.toBeInTheDocument())
})
})
@@ -2004,12 +2005,11 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
await screen.findByText('Alpha Widget')
await openDiscover(user)
await user.click(screen.getByTitle(/^Sort:/))
await user.click(screen.getByRole('button', { name: /^Most downloads$/ }))
expect(screen.getByTitle(/^Sort: Most downloads$/)).toBeInTheDocument()
await user.click(filterButton())
await user.click(screen.getByRole('menuitemradio', { name: /^Most downloads$/ }))
expect(filterButton()).toHaveTextContent('Most downloads')
await user.click(screen.getByRole('tab', { name: /Installed/ }))
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
})
it('FE-W5PLG-017: an outrun release offers the newest compatible version instead of a dead button', async () => {
@@ -2026,9 +2026,8 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
expect(offer).not.toBeDisabled()
expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0'))
const dead = screen.getByRole('button', { name: /^Incompatible$/ })
expect(dead).toBeDisabled()
expect(dead.getAttribute('title')).toBeTruthy()
// Nothing fits: no dead button any more, but a card that says so before anything installs.
expect(screen.getByText(/^Not confirmed for TREK .+ yet$/)).toBeInTheDocument()
})
it('FE-W5PLG-018: an already installed registry entry cannot be installed again', async () => {
@@ -2556,7 +2555,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
expect(await screen.findByText('Version checks off')).toBeInTheDocument()
})
it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => {
it('FE-PLG-BYPASS-002: offers Install for an incompatible entry and asks before installing', async () => {
let posted: unknown = null
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
@@ -2566,7 +2565,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
withToast()
fireEvent.click(await screen.findByText('Discover'))
const btn = await screen.findByRole('button', { name: /^install anyway$/i })
const btn = await screen.findByRole('button', { name: /^install$/i })
expect(btn).toBeEnabled()
fireEvent.click(btn)
@@ -2585,7 +2584,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
withToast()
fireEvent.click(await screen.findByText('Discover'))
fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }))
fireEvent.click(await screen.findByRole('button', { name: /^install$/i }))
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
+33 -384
View File
@@ -1,10 +1,9 @@
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { useState, type ReactNode } from 'react'
import {
Blocks, AlertTriangle, RefreshCw, Trash2, Download, Bug, ShieldCheck, UploadCloud,
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database,
Globe, Info, History, PauseCircle, Puzzle, Bot, Loader2,
ArrowUpCircle, Github, ExternalLink, Check, Search, Link2, KeyRound, ShieldAlert,
SlidersHorizontal, CircleDot, Database,
Globe, History, Puzzle, Bot, Loader2,
} from 'lucide-react'
import PluginIcon from '../shared/PluginIcon'
import { useInstanceSettings } from './useInstanceSettings'
@@ -23,15 +22,17 @@ import {
SettingsCard, SettingRows, SettingRow, SettingsHint, StatusPill,
SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON,
} from '../Settings/settingsKit'
import { POPOVER } from '../Packing/packingPopoverStyles'
import {
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, deriveDeps, fingerprint, formatCompactCount,
installOffer, isRegistrySourced, parseJson, signatureBlockView, sortLabel, statusLabel,
type PluginDep, type PluginRow, type RegistryItem, type SigSubject, type SortKey,
type StatusFilter, type T, type TypeFilter, type VersionInfo, type VersionMismatch,
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, fingerprint, formatCompactCount,
installOffer, signatureBlockView,
type PluginDep, type PluginRow, type RegistryItem, type SigSubject,
type T, type VersionInfo, type VersionMismatch,
} from './plugins/pluginModel'
import { usePluginDetail } from './plugins/usePluginDetail'
import { usePluginsAdmin } from './plugins/usePluginsAdmin'
import PluginFilterMenu from './plugins/PluginFilterMenu'
import PluginInstalledRow from './plugins/PluginInstalledRow'
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
/**
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
@@ -42,31 +43,13 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
* tile; the security section at the bottom explains the model honestly.
*/
// Runtime health → dot colour on the icon tile.
const HEALTH: Record<string, string> = {
active: 'bg-success',
starting: 'bg-info animate-pulse',
error: 'bg-danger',
inactive: 'bg-content-faint/60',
disabled: 'bg-warning',
incompatible: 'bg-warning',
}
/** The head band of a dialog that warns before something happens: a faint wash of the warning tone. */
const WARN_TINT = 'color-mix(in srgb, var(--warning) 10%, transparent)'
/** The eyebrow over a block, the size the dialogs' sections use. */
const EYEBROW = 'm-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
/** A capability or dependency chip on an installed row. */
const CHIP = 'inline-flex max-w-full items-center gap-1.5 rounded-full border px-2 py-[2px] font-medium'
const CHIP_NEUTRAL = 'border-edge-faint bg-surface-secondary text-content-secondary'
const CHIP_NET = 'border-transparent bg-info-soft text-info'
const CHIP_WARN = 'border-transparent bg-warning-soft text-warning'
/** A small action inside a row or a card: the kit's button, a size down. */
const SMALL_BUTTON = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-semibold text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
const SMALL_PRIMARY = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 py-1.5 font-semibold text-accent-text hover:opacity-90 disabled:cursor-default disabled:bg-surface-tertiary disabled:text-content-faint'
/** A line of state under a row's name: a refused update, a paused one, an error. */
const ROW_NOTE = 'mt-1.5 flex min-w-0 items-center gap-1.5'
/** A pill that explains itself on hover. */
function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
return (
@@ -76,19 +59,6 @@ function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
)
}
function ReviewedBadge({ t }: { t: T }) {
return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
}
/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */
function SideloadedBadge({ t }: { t: T }) {
return (
<HintPill hint={t('admin.plugins.sideloadedHint')}>
<StatusPill tone="warning" icon={<UploadCloud size={11} />}>{t('admin.plugins.sideloaded')}</StatusPill>
</HintPill>
)
}
/**
* Signed / Unsigned, for registry plugins only (see isRegistrySourced).
*
@@ -113,15 +83,6 @@ function TrustBadge({ signed, t }: { signed: boolean; t: T }) {
)
}
/** Marks a dev-linked plugin: loaded from a local build dir + hot-reloaded (dev only). */
function DevLinkBadge({ t }: { t: T }) {
return (
<HintPill hint={t('admin.plugins.devLinkHint')}>
<StatusPill icon={<Link2 size={11} />}>{t('admin.plugins.devLinkBadge')}</StatusPill>
</HintPill>
)
}
function TypeBadge({ type, t }: { type: string; t: T }) {
return (
<span className="inline-flex flex-none items-center rounded-full bg-surface-tertiary px-2 py-[3px] font-geist font-bold uppercase tracking-[.06em] text-content-muted" style={fs(9.5)}>
@@ -145,6 +106,7 @@ export default function AdminPluginsPanel() {
sort, statusFilter, toggle, typeFilter, uninstallConfirmed, updatable, updateAll, updateAvailable, uploadInputRef,
uploadPlugin, versionPicker, view,
} = usePluginsAdmin()
const [securityOpen, setSecurityOpen] = useState(false)
return (
<div className="relative mb-24 sm:mb-0"
@@ -181,6 +143,9 @@ export default function AdminPluginsPanel() {
) : undefined}
action={ready ? (
<>
<BandButton label={t('admin.plugins.security.short')} onClick={() => setSecurityOpen(true)}>
<ShieldCheck size={15} strokeWidth={2} />
</BandButton>
<BandButton label={t('admin.plugins.upload')} onClick={pickUpload} disabled={busy === '__upload'}>
<UploadCloud size={15} strokeWidth={2} />
</BandButton>
@@ -242,31 +207,9 @@ export default function AdminPluginsPanel() {
/>
</label>
<FilterMenu id="type" label={t('admin.plugins.filterType')} value={typeFilter} menu={menu} setMenu={setMenu} icon={<SlidersHorizontal size={14} />}
options={[
['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')],
['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')],
['trip-page', t('admin.plugins.type.trip-page')],
]}
valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)}
onPick={v => setTypeFilter(v as TypeFilter)} />
{view === 'installed' && (
<FilterMenu id="status" label={t('admin.plugins.filterStatus')} value={statusFilter} menu={menu} setMenu={setMenu} icon={<CircleDot size={14} />}
options={[
['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')],
['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')],
]}
valueLabel={statusLabel(statusFilter, t)}
onPick={v => setStatusFilter(v as StatusFilter)} />
)}
<FilterMenu id="sort" label={t('admin.plugins.sortBy')} value={sort} menu={menu} setMenu={setMenu} icon={<ArrowUpDown size={14} />}
options={view === 'discover'
? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]]
: [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]}
valueLabel={sortLabel(sort, t)}
onPick={v => setSort(v as SortKey)} />
<PluginFilterMenu t={t} view={view} typeFilter={typeFilter} setTypeFilter={setTypeFilter}
statusFilter={statusFilter} setStatusFilter={setStatusFilter} sort={sort} setSort={setSort}
menu={menu} setMenu={setMenu} />
</div>
{view === 'discover' ? (
@@ -299,7 +242,7 @@ export default function AdminPluginsPanel() {
) : (
<SettingRows>
{shownInstalled.map(p => (
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
<PluginInstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
newerIncompatible={newerIncompatible(p)}
blocked={blockIsCurrent(p, latest[p.id])}
@@ -320,7 +263,7 @@ export default function AdminPluginsPanel() {
)}
</SettingsCard>
<SecurityInfo t={t} />
{securityOpen && <PluginSecurityDialog t={t} onClose={() => setSecurityOpen(false)} />}
{/* Registry detail dialog */}
{detailFor && (
@@ -438,268 +381,6 @@ function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: (
)
}
/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */
function useFollowTrigger(open: boolean) {
const [, reanchor] = useState(0)
useEffect(() => {
if (!open) return
const onMove = () => reanchor(n => n + 1)
window.addEventListener('scroll', onMove, true)
window.addEventListener('resize', onMove)
return () => {
window.removeEventListener('scroll', onMove, true)
window.removeEventListener('resize', onMove)
}
}, [open])
}
function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMenu, icon }: {
id: string; label: string; valueLabel: string
options: Array<[string, string]>; onPick: (v: string) => void; value: string
menu: string | null; setMenu: (v: string | null) => void; icon?: ReactNode
}) {
const open = menu === id
const active = value !== options[0]?.[0]
const btnRef = useRef<HTMLButtonElement>(null)
// Portaled like the row menu: the card around the toolbar clips what overflows it.
useFollowTrigger(open)
return (
<div className="relative flex-none">
<button type="button" ref={btnRef} onClick={() => setMenu(open ? null : id)} title={`${label}: ${valueLabel}`} aria-expanded={open}
className={`inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
active ? 'border-[color:var(--text-primary)]' : 'border-edge'}`}
style={fs(12.5, 'body')}>
<span className="flex-none text-content-faint">{icon}</span>
<span>{label}: </span>
<span className="font-semibold text-content">{valueLabel}</span>
<ChevronDown size={13} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && createPortal(
<div className="min-w-[200px] max-w-[calc(100vw-2rem)]" style={{ ...POPOVER, ...anchorMenu(btnRef.current) }}>
{options.map(([v, lbl]) => (
<button type="button" key={v} onClick={() => { onPick(v); setMenu(null) }}
className={`${MENU_ITEM} ${value === v ? 'font-semibold text-content' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
<span className="min-w-0 flex-1 truncate text-start">{lbl}</span>
<Check size={14} className={`flex-none text-content-muted ${value === v ? 'opacity-100' : 'opacity-0'}`} />
</button>
))}
</div>,
document.body,
)}
</div>
)
}
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: {
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void
}) {
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
const deps = deriveDeps(p, t)
const menuOpen = menu === `row:${p.id}`
const menuBtnRef = useRef<HTMLButtonElement>(null)
const working = busy === p.id
const on = p.enabled === 1 && p.status !== 'error'
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
// Re-anchor while the page moves under it.
useFollowTrigger(menuOpen)
const hostCount = p.egressHostCount ?? 0
return (
<div className={`group relative flex items-start gap-3 px-3.5 py-3 transition-colors hover:bg-surface-secondary ${working ? 'opacity-60' : ''}`}>
<div className="relative mt-0.5 flex-none">
<span className="grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-secondary">
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
</span>
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
<span className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`} />
</Tooltip>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="min-w-0 truncate font-semibold tracking-[-.006em] text-content" style={fs(13.5, 'body')}>{p.name}</span>
{p.version && <span className="font-geist font-medium tabular-nums text-content-faint" style={fs(11)}>v{p.version}</span>}
{p.reviewed_at && <ReviewedBadge t={t} />}
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
{p.source_repo === 'local:link' && <DevLinkBadge t={t} />}
{/* Registry plugins only — a sideloaded/dev-linked plugin already says something
strictly stronger, and stacking "Unsigned" on top of it just dilutes the amber. */}
{isRegistrySourced(p.source_repo) && <TrustBadge signed={!!p.signed} t={t} />}
</div>
{p.description && <p className="m-0 mt-0.5 truncate text-content-muted" style={fs(12, 'body')}>{p.description}</p>}
{/* A refused update leaves a WORKING plugin pinned at its old version — so this is
its own row state, not an error state, and it persists until it is resolved
rather than dying with the toast that first reported it. */}
{blocked && p.updateBlock && (
<div className={`${ROW_NOTE} text-warning`} style={fs(11.5)}>
<ShieldAlert size={13} className="flex-none" />
<span className="truncate">{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}</span>
<button type="button" onClick={onReviewBlock}
className="flex-none font-semibold underline underline-offset-2 hover:opacity-80">
{t('admin.plugins.reviewBlock')}
</button>
</div>
)}
{/* A newer version this TREK can't run — informational, never a button: the fix
is a TREK upgrade, so the row must not nag or offer a doomed install. */}
{newerIncompatible && (
<div className={`${ROW_NOTE} text-content-faint`} style={fs(11.5)}>
<Info size={13} className="flex-none" />
<span className="truncate">{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}</span>
</div>
)}
{/* Held: the admin deliberately installed a non-latest version, so updates are
paused rather than nagged — resuming is one click, right where the pause shows. */}
{p.updateHold && (
<div className={`${ROW_NOTE} text-content-faint`} style={fs(11.5)}>
<PauseCircle size={13} className="flex-none" />
<span className="truncate">{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}</span>
<button type="button" onClick={onResume} disabled={working}
className="flex-none font-semibold text-content-secondary underline underline-offset-2 hover:opacity-80 disabled:opacity-50">
{t('admin.plugins.resumeUpdates')}
</button>
</div>
)}
{p.status === 'error' && p.last_error ? (
<div className={`${ROW_NOTE} text-danger`} style={fs(11.5)}>
<AlertTriangle size={13} className="flex-none" /><span className="truncate">{p.last_error}</span>
</div>
) : (caps.length > 0 || p.operatorEgress) && (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{caps.map((c, i) => (
<span key={i} className={`${CHIP} ${c.net ? CHIP_NET : CHIP_NEUTRAL}`} style={fs(11)}>
<c.icon size={12} className={c.net ? 'text-info' : 'text-content-muted'} /><span className="truncate">{c.label}</span>
</span>
))}
{/* This plugin talks to a service only the OPERATOR can name (a self-hosted
Gotify/ntfy), so its manifest can't list the host — the admin adds it.
Actionable, and warning-toned until at least one host exists, because
until then the plugin cannot reach anything and looks silently broken. */}
{p.operatorEgress && (
<Tooltip label={t('admin.plugins.allowedHosts.hint')}>
<button type="button" onClick={onEgress}
className={`${CHIP} ${hostCount > 0 ? CHIP_NET : CHIP_WARN} hover:opacity-80`} style={fs(11)}>
<Globe size={12} />
{hostCount > 0
? t('admin.plugins.allowedHosts.count', { n: hostCount })
: t('admin.plugins.allowedHosts.add')}
</button>
</Tooltip>
)}
</div>
)}
{deps.length > 0 && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{deps.map((d, i) => (
<span key={i} className={`${CHIP} ${d.blocked || d.warn ? CHIP_WARN : CHIP_NEUTRAL}`} style={fs(11)}>
<d.icon size={12} className={d.blocked || d.warn ? 'text-warning' : 'text-content-muted'} /><span className="truncate">{d.label}</span>
</span>
))}
</div>
)}
</div>
<div className="flex flex-none items-center gap-2 self-center">
{hasUpdate && (
<button type="button" onClick={onUpdate} disabled={working}
className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-1 font-semibold text-warning hover:opacity-90 disabled:opacity-50"
style={fs(11.5)}>
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
</button>
)}
<span className={`min-w-[42px] text-end font-medium ${on ? 'text-content-secondary' : 'text-content-faint'}`} style={fs(12)}>
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
</span>
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={onToggle} />
<div className="relative">
<Tooltip label={t('admin.table.actions')} disabled={menuOpen}>
<button type="button" ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
aria-expanded={menuOpen} aria-label={t('admin.table.actions')}
className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}>
<MoreHorizontal size={17} />
</button>
</Tooltip>
{menuOpen && createPortal(
<div data-testid={`plugin-row-menu-${p.id}`} style={{ ...POPOVER, ...anchorMenu(menuBtnRef.current) }}
className="min-w-[200px]">
{p.enabled === 1 && (
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
)}
{/* A plugin gets the item if it declares scope:'instance' fields OR actions —
an action-only plugin still needs the dialog to run its buttons. */}
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
<MenuItem icon={<SlidersHorizontal size={14} />} label={t('admin.plugins.instanceSettings')} onClick={onSettings} />
)}
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
{/* Only a plugin that DECLARED operatorEgress gets the item — an admin must
never be invited to widen egress for a plugin that didn't ask for it
(same rule the row's egress chip follows). */}
{p.operatorEgress && (
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
)}
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
{isRegistrySourced(p.source_repo) && (
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
)}
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
<>
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}>
<span className="flex w-4 flex-none justify-center text-content-muted"><Github size={14} /></span> {t('admin.plugins.sourceRepo')}
</a>
<a href={`https://github.com/${p.source_repo}/issues`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}>
<span className="flex w-4 flex-none justify-center text-content-muted"><CircleDot size={14} /></span> {t('admin.plugins.reportIssue')}
</a>
</>
)}
<div className="mx-1.5 my-1 h-px bg-edge-faint" />
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={onUninstall} />
</div>,
document.body,
)}
</div>
</div>
</div>
)
}
/** Tallest the ⋯ menu gets (6 items + divider + padding). Drives the flip. */
const ROW_MENU_MAX_H = 260
/** Pin a portaled menu to the right edge of its trigger, flipping up when the bottom is tight. */
function anchorMenu(trigger: HTMLElement | null): CSSProperties {
const r = trigger?.getBoundingClientRect()
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 }
const spaceBelow = window.innerHeight - r.bottom
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow
return {
position: 'fixed',
right: Math.max(8, window.innerWidth - r.right),
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
zIndex: 100,
}
}
/** One entry of a popover menu, in the packing popovers' shape. */
const MENU_ITEM = 'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary'
function MenuItem({ icon, label, onClick, danger }: { icon: ReactNode; label: string; onClick: () => void; danger?: boolean }) {
return (
<button type="button" onClick={onClick}
className={`${MENU_ITEM} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
<span className={`flex w-4 flex-none justify-center ${danger ? 'text-danger' : 'text-content-muted'}`}>{icon}</span> {label}
</button>
)
}
function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) {
const [failed, setFailed] = useState(false)
return (
@@ -751,6 +432,16 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
<StatusPill icon={<ShieldCheck size={11} className="text-success" />}>{t('admin.plugins.reviewed')}</StatusPill>
</span>
)}
{/* Says up front that this one has not been confirmed for the TREK it would run on,
so "Install" never reads as a plain install; the dialog explains the risk. */}
{!installed && offer.warn && (
// Solid, not the soft pill "Reviewed" uses: it has to stand out on any screenshot.
<span className="absolute start-2.5 top-2.5 inline-flex max-w-[62%] items-center gap-1 rounded-full bg-warning px-2 py-[3px] font-geist font-semibold text-[color:var(--surface-card)] shadow-md"
style={fs(11)}>
<AlertTriangle size={11} className="flex-none" />
<span className="truncate">{t('admin.plugins.untestedChip', { host: offer.warn.hostVersion })}</span>
</span>
)}
<span className="absolute -bottom-4 start-3.5 z-[1] grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-card shadow-sm">
<PluginIcon name={item.icon} size={20} className="text-content-secondary" />
</span>
@@ -829,8 +520,8 @@ function Tile({ children, warn }: { children: ReactNode; warn?: boolean }) {
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
* component rather than ConfirmDialog because the notice mode has no cancel button.
*/
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T
function RangeBypassDialog({ copy: { title, body, confirm, confirmLabel }, onConfirm, onClose, t }: {
copy: { title: string; body: string; confirm: boolean; confirmLabel: string }; onConfirm: () => void; onClose: () => void; t: T
}) {
return (
<DialogShell
@@ -844,7 +535,7 @@ function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose,
{confirm ? (
<>
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={onConfirm}>{t('admin.plugins.installAnyway')}</DialogButton>
<DialogButton variant="primary" onClick={onConfirm}>{confirmLabel}</DialogButton>
</>
) : (
<DialogButton variant="primary" onClick={onClose}>{t('common.ok')}</DialogButton>
@@ -1495,45 +1186,3 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
</DialogShell>
)
}
// Footer: a plain-language note on what "Reviewed" means, plus a collapsible
// panel that lays out how plugins are contained, the limits, and the worst case.
function SecurityInfo({ t }: { t: T }) {
const [open, setOpen] = useState(false)
const sections: Array<[string, string]> = [
['admin.plugins.security.isolationTitle', 'admin.plugins.security.isolationBody'],
['admin.plugins.security.permsTitle', 'admin.plugins.security.permsBody'],
['admin.plugins.security.limitsTitle', 'admin.plugins.security.limitsBody'],
['admin.plugins.security.worstTitle', 'admin.plugins.security.worstBody'],
['admin.plugins.security.reviewedTitle', 'admin.plugins.security.reviewedBody'],
['admin.plugins.security.signedTitle', 'admin.plugins.security.signedBody'],
['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'],
]
return (
<SettingsCard
icon={Lock}
title={(
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open}
className="inline-flex max-w-full items-center gap-1.5 text-start font-bold text-content hover:opacity-80">
<span className="truncate">{t('admin.plugins.security.title')}</span>
<ChevronDown size={15} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
)}
>
<div className="flex items-start gap-2.5">
<ShieldCheck size={14} className="mt-0.5 flex-none text-content-faint" />
<SettingsHint>{t('admin.plugins.reviewedMeaning')}</SettingsHint>
</div>
{open && (
<div className="grid gap-2.5 sm:grid-cols-2">
{sections.map(([h, b]) => (
<div key={h} className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
<h4 className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>{t(h as never)}</h4>
<p className="m-0 mt-1 leading-relaxed text-content-muted" style={fs(12, 'body')}>{t(b as never)}</p>
</div>
))}
</div>
)}
</SettingsCard>
)
}
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router';
import { openrouterReturnCode } from '../../Settings/ai/aiConnectModel';
import type { Addon } from './addonModel';
import { usesConnection } from './aiAddons';
@@ -11,7 +12,7 @@ import { usesConnection } from './aiAddons';
*/
export function useAiAddonTile(aiAddons: Addon[]) {
const [params] = useSearchParams();
const [open, setOpen] = useState(() => params.get('ai') === 'openrouter');
const [open, setOpen] = useState(() => openrouterReturnCode(params) !== undefined);
const canSetUp = usesConnection(aiAddons);
return {
enabledCount: aiAddons.filter((a) => a.enabled).length,
@@ -1,4 +1,4 @@
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-007: the AI assistant's settings and connection for everyone.
// FE-HOOK-AI-ADMIN-001 to FE-HOOK-AI-ADMIN-008: the AI assistant's settings and connection for everyone.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { AiAdminConnectionResponse, AiAdminSettings } from '@trek/shared';
import { MASKED_SETTING_VALUE } from '@trek/shared';
@@ -16,6 +16,7 @@ const repo = vi.hoisted(() => ({
saveAdminSettings: vi.fn(),
adminConnection: vi.fn(),
saveAdminConnection: vi.fn(),
setAdminModel: vi.fn(),
removeAdminConnection: vi.fn(),
adminModels: vi.fn(),
}));
@@ -157,6 +158,29 @@ describe('useAiAdminConfig', () => {
});
});
it("FE-HOOK-AI-ADMIN-008: a shared ChatGPT plan's model is switched on its own, and the answer shows (#2783)", async () => {
const plan = (model: string): AiAdminConnectionResponse => ({
connection: {
scope: 'instance',
kind: 'chatgpt',
model,
hint: null,
status: 'ok',
host: 'api.openai.com',
baseUrl: null,
},
prefill: null,
});
repo.adminConnection.mockResolvedValue(plan('gpt-6.1-sol'));
repo.setAdminModel.mockResolvedValue(plan('gpt-6.1-sol-mini'));
const { result } = await renderLoaded();
await act(async () => result.current.instance.setModel('gpt-6.1-sol-mini'));
await waitFor(() => expect(repo.setAdminModel).toHaveBeenCalledWith('gpt-6.1-sol-mini'));
expect(repo.saveAdminConnection).not.toHaveBeenCalled();
await waitFor(() => expect(result.current.instance.live?.model).toBe('gpt-6.1-sol-mini'));
expect(toast.success).toHaveBeenCalled();
});
it('FE-HOOK-AI-ADMIN-007: removing asks first, then shows the setup again; replacing opens it beside the old one', async () => {
repo.adminConnection.mockResolvedValueOnce(LIVE).mockResolvedValue(NONE);
const { result } = await renderLoaded();
@@ -124,9 +124,16 @@ function useInstanceConnection(onChanged: () => void) {
}
};
/** Another model on the same connection: the stored key is kept by sending the mask in its place. */
/**
* Another model on the same connection: the stored key is kept by sending the mask in its place.
* A shared ChatGPT plan has a sign-in, not a key, so its model is switched on its own (#2783).
*/
const setModel = (model: string) => {
if (!live || live.kind === 'chatgpt') return;
if (!live) return;
if (live.kind === 'chatgpt') {
void act(() => aiConnectionsRepo.setAdminModel(model), 'common.saved');
return;
}
const request: AiInstanceConnectionRequest =
live.kind === 'custom'
? { kind: 'custom', baseUrl: live.baseUrl ?? '', model, ...(live.hint ? { apiKey: MASKED_SETTING_VALUE } : {}) }
@@ -0,0 +1,167 @@
import { Check, ChevronDown, SlidersHorizontal } from 'lucide-react';
import { useRef } from 'react';
import { createPortal } from 'react-dom';
import { POPOVER } from '../../Packing/packingPopoverStyles';
import { fs } from '../../shared/DialogShell';
import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu';
import { sortLabel, type SortKey, type StatusFilter, type T, type TypeFilter } from './pluginModel';
const MENU_ID = 'filters';
interface Section {
title: string;
value: string;
options: Array<[string, string]>;
onPick: (v: string) => void;
}
/**
* Type, status and order of the plugin list behind one button instead of three, so the
* toolbar stays search plus one control. The button counts the filters that narrow the
* list (the order does not narrow it) and names the order it is in.
*/
export default function PluginFilterMenu({
t,
view,
typeFilter,
setTypeFilter,
statusFilter,
setStatusFilter,
sort,
setSort,
menu,
setMenu,
}: {
t: T;
view: 'installed' | 'discover';
typeFilter: TypeFilter;
setTypeFilter: (v: TypeFilter) => void;
statusFilter: StatusFilter;
setStatusFilter: (v: StatusFilter) => void;
sort: SortKey;
setSort: (v: SortKey) => void;
menu: string | null;
setMenu: (v: string | null) => void;
}) {
const open = menu === MENU_ID;
const btnRef = useRef<HTMLButtonElement>(null);
useFollowTrigger(open);
const sections: Section[] = [
{
title: t('admin.plugins.filterType'),
value: typeFilter,
options: [
['all', t('admin.plugins.allTypes')],
['widget', t('admin.plugins.type.widget')],
['integration', t('admin.plugins.type.integration')],
['page', t('admin.plugins.type.page')],
['trip-page', t('admin.plugins.type.trip-page')],
],
onPick: (v) => setTypeFilter(v as TypeFilter),
},
...(view === 'installed'
? [
{
title: t('admin.plugins.filterStatus'),
value: statusFilter,
options: [
['all', t('admin.plugins.allStatuses')],
['on', t('admin.plugins.status.active')],
['off', t('admin.plugins.stateOff')],
['update', t('admin.plugins.filterUpdate')],
['err', t('admin.plugins.status.error')],
] as Array<[string, string]>,
onPick: (v: string) => setStatusFilter(v as StatusFilter),
},
]
: []),
{
title: t('admin.plugins.sortBy'),
value: sort,
options:
view === 'discover'
? [
['name', t('admin.plugins.sortName')],
['recent', t('admin.plugins.sortRecent')],
['downloads', t('admin.plugins.sortDownloads')],
]
: [
['name', t('admin.plugins.sortName')],
['recent', t('admin.plugins.sortRecent')],
['updates', t('admin.plugins.sortUpdates')],
],
onPick: (v) => setSort(v as SortKey),
},
];
const narrowing = (typeFilter !== 'all' ? 1 : 0) + (view === 'installed' && statusFilter !== 'all' ? 1 : 0);
return (
<div className="relative flex-none">
<button
type="button"
ref={btnRef}
onClick={() => setMenu(open ? null : MENU_ID)}
aria-expanded={open}
aria-haspopup="menu"
className={`inline-flex h-9 items-center gap-2 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
narrowing ? 'border-[color:var(--text-primary)]' : 'border-edge'
}`}
style={fs(12.5, 'body')}
>
<SlidersHorizontal size={14} className="flex-none text-content-faint" />
<span className="font-semibold text-content">{t('admin.plugins.filters')}</span>
{narrowing > 0 && (
<span
className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-accent px-1.5 font-geist font-bold tabular-nums text-accent-text"
style={fs(10.5)}
>
{narrowing}
</span>
)}
<span className="text-content-faint">{sortLabel(sort, t)}</span>
<ChevronDown
size={13}
className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open &&
createPortal(
<div
role="menu"
className="w-[240px] max-w-[calc(100vw-2rem)]"
style={{ ...POPOVER, ...anchorMenu(btnRef.current) }}
>
{sections.map((s, i) => (
<div key={s.title} className={i > 0 ? 'mt-1.5 border-t border-edge-faint pt-1.5' : undefined}>
<p
className="m-0 px-2.5 pb-1 pt-1 font-geist font-bold uppercase tracking-[.08em] text-content-faint"
style={fs(10)}
>
{s.title}
</p>
{s.options.map(([v, label]) => (
<button
type="button"
role="menuitemradio"
aria-checked={s.value === v}
key={v}
onClick={() => s.onPick(v)}
className={`${MENU_ITEM} ${s.value === v ? 'font-semibold text-content' : 'text-content-secondary'}`}
style={fs(12.5, 'body')}
>
<span className="min-w-0 flex-1 truncate text-start">{label}</span>
<Check
size={14}
className={`flex-none text-content-muted ${s.value === v ? 'opacity-100' : 'opacity-0'}`}
/>
</button>
))}
</div>
))}
</div>,
document.body
)}
</div>
);
}
@@ -0,0 +1,426 @@
import {
AlertTriangle,
ArrowUpCircle,
Bug,
CircleDot,
Github,
Globe,
History,
Info,
KeyRound,
Link2,
MoreHorizontal,
PauseCircle,
RotateCw,
ShieldAlert,
ShieldCheck,
SlidersHorizontal,
Trash2,
UploadCloud,
} from 'lucide-react';
import { useRef, type ComponentType, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { POPOVER } from '../../Packing/packingPopoverStyles';
import ToggleSwitch from '../../Settings/ToggleSwitch';
import { fs } from '../../shared/DialogShell';
import PluginIcon from '../../shared/PluginIcon';
import { Tooltip } from '../../shared/Tooltip';
import { deriveCaps } from '../pluginCaps';
import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu';
import { deriveDeps, isRegistrySourced, parseJson, type PluginRow, type T } from './pluginModel';
// Runtime health → dot colour on the icon tile.
const HEALTH: Record<string, string> = {
active: 'bg-success',
starting: 'bg-info animate-pulse',
error: 'bg-danger',
inactive: 'bg-content-faint/60',
disabled: 'bg-warning',
incompatible: 'bg-warning',
};
/** Every fact about a plugin wears the same chip; only its icon carries a colour. */
const FACT =
'inline-flex max-w-full items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2 py-[2px] font-medium text-content-secondary';
type Tone = 'warning' | 'danger' | 'neutral';
const NOTICE_LOOK: Record<Tone, string> = {
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
neutral: 'bg-surface-secondary text-content-muted',
};
/** A state of the plugin worth reading on its own line: what it means, and the one action that fixes it. */
interface Notice {
key: string;
tone: Tone;
icon: ComponentType<{ size?: number; className?: string }>;
text: string;
action?: { label: string; onClick: () => void; disabled?: boolean };
}
function Fact({
icon,
iconClass = 'text-content-muted',
hint,
children,
}: {
icon: ReactNode;
iconClass?: string;
hint?: string;
children: ReactNode;
}) {
const chip = (
<span className={FACT} style={fs(11)}>
<span className={`flex flex-none ${iconClass}`}>{icon}</span>
<span className="truncate">{children}</span>
</span>
);
return hint ? (
<Tooltip label={hint}>
<span className="inline-flex max-w-full">{chip}</span>
</Tooltip>
) : (
chip
);
}
function NoticeLine({ n }: { n: Notice }) {
return (
<div
className={`flex min-w-0 items-center gap-2 rounded-[10px] px-2.5 py-1.5 ${NOTICE_LOOK[n.tone]}`}
style={fs(11.5)}
>
<n.icon size={13} className="flex-none" />
<span className="min-w-0 flex-1 truncate">{n.text}</span>
{n.action && (
<button
type="button"
onClick={n.action.onClick}
disabled={n.action.disabled}
className="flex-none font-semibold underline underline-offset-2 hover:opacity-80 disabled:opacity-50"
>
{n.action.label}
</button>
)}
</div>
);
}
function MenuItem({
icon,
label,
onClick,
danger,
}: {
icon: ReactNode;
label: string;
onClick: () => void;
danger?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={`${MENU_ITEM} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary'}`}
style={fs(12.5, 'body')}
>
<span className={`flex w-4 flex-none justify-center ${danger ? 'text-danger' : 'text-content-muted'}`}>
{icon}
</span>{' '}
{label}
</button>
);
}
interface InstalledRowProps {
p: PluginRow;
t: T;
busy: string | null;
menu: string | null;
setMenu: (v: string | null) => void;
hasUpdate: boolean;
latestVer?: string;
newerIncompatible: { version: string; range: string } | null;
blocked: boolean;
onToggle: () => void;
onUpdate: () => void;
onRestart: () => void;
onChangeVersion: () => void;
onResume: () => void;
onReviewBlock: () => void;
onErrors: () => void;
onEgress: () => void;
onSettings: () => void;
onUninstall: () => void;
}
/**
* One installed plugin, read top to bottom: its name and version, what it does, the
* facts about it as one row of alike chips (what it adds, whether it was reviewed and
* signed, where it may connect), then each state that needs attention on a line of its
* own with the action that resolves it. The controls stay on the right.
*/
export default function PluginInstalledRow(props: InstalledRowProps) {
const { p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked } = props;
const caps = deriveCaps(
parseJson<string[]>(p.permissions, []),
parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}),
t
);
const deps = deriveDeps(p, t);
const menuOpen = menu === `row:${p.id}`;
const menuBtnRef = useRef<HTMLButtonElement>(null);
const working = busy === p.id;
const hostCount = p.egressHostCount ?? 0;
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
useFollowTrigger(menuOpen);
const notices: Notice[] = [];
if (p.status === 'error' && p.last_error) {
notices.push({ key: 'error', tone: 'danger', icon: AlertTriangle, text: p.last_error });
}
// A refused update leaves a WORKING plugin pinned at its old version: its own state,
// kept until resolved rather than dying with the toast that first reported it.
if (blocked && p.updateBlock) {
notices.push({
key: 'block',
tone: 'warning',
icon: ShieldAlert,
text: t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code }),
action: { label: t('admin.plugins.reviewBlock'), onClick: props.onReviewBlock },
});
}
for (const [i, d] of deps.entries()) {
if (d.blocked || d.warn) notices.push({ key: `dep${i}`, tone: 'warning', icon: d.icon, text: d.label });
}
// Held: the admin deliberately installed a non-latest version, so updates are paused
// rather than nagged — resuming is one click, right where the pause shows.
if (p.updateHold) {
notices.push({
key: 'hold',
tone: 'neutral',
icon: PauseCircle,
text: t('admin.plugins.updatesHeld', { version: p.version ?? '' }),
action: { label: t('admin.plugins.resumeUpdates'), onClick: props.onResume, disabled: working },
});
}
// A newer version this TREK can't run: informational, never a button, the fix is a TREK upgrade.
if (newerIncompatible) {
notices.push({
key: 'newer',
tone: 'neutral',
icon: Info,
text: t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range }),
});
}
return (
<div
className={`group relative flex items-start gap-3 px-3.5 py-3.5 transition-colors hover:bg-surface-secondary ${working ? 'opacity-60' : ''}`}
>
<div className="relative mt-0.5 flex-none">
<span className="grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-secondary">
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
</span>
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
<span
className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`}
/>
</Tooltip>
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<span className="min-w-0 truncate font-semibold tracking-[-.006em] text-content" style={fs(13.5, 'body')}>
{p.name}
</span>
{p.version && (
<span className="flex-none font-geist font-medium tabular-nums text-content-faint" style={fs(11)}>
v{p.version}
</span>
)}
</div>
{p.description && (
<p className="m-0 mt-0.5 truncate text-content-muted" style={fs(12, 'body')}>
{p.description}
</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{caps.map((c, i) => (
<Fact key={i} icon={<c.icon size={12} />} iconClass={c.net ? 'text-info' : 'text-content-muted'}>
{c.label}
</Fact>
))}
{deps
.filter((d) => !d.blocked && !d.warn)
.map((d, i) => (
<Fact key={`d${i}`} icon={<d.icon size={12} />}>
{d.label}
</Fact>
))}
{p.reviewed_at && (
<Fact icon={<ShieldCheck size={12} />} iconClass="text-success">
{t('admin.plugins.reviewed')}
</Fact>
)}
{p.source_repo === 'local:upload' && (
<Fact icon={<UploadCloud size={12} />} iconClass="text-warning" hint={t('admin.plugins.sideloadedHint')}>
{t('admin.plugins.sideloaded')}
</Fact>
)}
{p.source_repo === 'local:link' && (
<Fact icon={<Link2 size={12} />} hint={t('admin.plugins.devLinkHint')}>
{t('admin.plugins.devLinkBadge')}
</Fact>
)}
{/* Registry plugins only: a sideloaded or dev-linked plugin already says something stronger. */}
{isRegistrySourced(p.source_repo) &&
(p.signed ? (
<Fact icon={<KeyRound size={12} />} hint={t('admin.plugins.signedHint')}>
{t('admin.plugins.signed')}
</Fact>
) : (
<Fact icon={<ShieldAlert size={12} />} iconClass="text-warning" hint={t('admin.plugins.unsignedHint')}>
{t('admin.plugins.unsigned')}
</Fact>
))}
{/* A service only the operator can name (a self-hosted Gotify/ntfy): the admin adds
its host, and until one exists the plugin reaches nothing, so it asks for it. */}
{p.operatorEgress && (
<Tooltip label={t('admin.plugins.allowedHosts.hint')}>
<button type="button" onClick={props.onEgress} className={`${FACT} hover:opacity-80`} style={fs(11)}>
<Globe size={12} className={hostCount > 0 ? 'text-info' : 'text-warning'} />
{hostCount > 0
? t('admin.plugins.allowedHosts.count', { n: hostCount })
: t('admin.plugins.allowedHosts.add')}
</button>
</Tooltip>
)}
</div>
{notices.length > 0 && (
<div className="mt-2.5 flex flex-col gap-1.5">
{notices.map((n) => (
<NoticeLine key={n.key} n={n} />
))}
</div>
)}
</div>
<div className="flex flex-none items-center gap-2 self-center">
{hasUpdate && (
<button
type="button"
onClick={props.onUpdate}
disabled={working}
className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-1 font-semibold text-warning hover:opacity-90 disabled:opacity-50"
style={fs(11.5)}
>
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
</button>
)}
<span
className={`min-w-[42px] text-end font-medium ${p.enabled === 1 ? 'text-content-secondary' : 'text-content-faint'}`}
style={fs(12)}
>
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
</span>
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={props.onToggle} />
<div className="relative">
<Tooltip label={t('admin.table.actions')} disabled={menuOpen}>
<button
type="button"
ref={menuBtnRef}
data-testid={`plugin-row-menu-btn-${p.id}`}
onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
aria-expanded={menuOpen}
aria-label={t('admin.table.actions')}
className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}
>
<MoreHorizontal size={17} />
</button>
</Tooltip>
{menuOpen &&
createPortal(
<div
data-testid={`plugin-row-menu-${p.id}`}
style={{ ...POPOVER, ...anchorMenu(menuBtnRef.current) }}
className="min-w-[200px]"
>
{p.enabled === 1 && (
<MenuItem
icon={<RotateCw size={14} />}
label={t('admin.plugins.restart')}
onClick={props.onRestart}
/>
)}
{/* Instance fields OR actions: an action-only plugin still needs the dialog to run its buttons. */}
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
<MenuItem
icon={<SlidersHorizontal size={14} />}
label={t('admin.plugins.instanceSettings')}
onClick={props.onSettings}
/>
)}
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={props.onErrors} />
{/* Only a plugin that DECLARED operatorEgress: an admin is never invited to widen egress for one that did not ask. */}
{p.operatorEgress && (
<MenuItem
icon={<Globe size={14} />}
label={t('admin.plugins.allowedHosts')}
onClick={props.onEgress}
/>
)}
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
{isRegistrySourced(p.source_repo) && (
<MenuItem
icon={<History size={14} />}
label={t('admin.plugins.changeVersion')}
onClick={props.onChangeVersion}
/>
)}
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
<>
<a
href={`https://github.com/${p.source_repo}`}
target="_blank"
rel="noreferrer"
onClick={() => setMenu(null)}
className={`${MENU_ITEM} text-content-secondary`}
style={fs(12.5, 'body')}
>
<span className="flex w-4 flex-none justify-center text-content-muted">
<Github size={14} />
</span>{' '}
{t('admin.plugins.sourceRepo')}
</a>
<a
href={`https://github.com/${p.source_repo}/issues`}
target="_blank"
rel="noreferrer"
onClick={() => setMenu(null)}
className={`${MENU_ITEM} text-content-secondary`}
style={fs(12.5, 'body')}
>
<span className="flex w-4 flex-none justify-center text-content-muted">
<CircleDot size={14} />
</span>{' '}
{t('admin.plugins.reportIssue')}
</a>
</>
)}
<div className="mx-1.5 my-1 h-px bg-edge-faint" />
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={props.onUninstall} />
</div>,
document.body
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { ShieldCheck } from 'lucide-react';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
NEUTRAL_TINT,
fs,
} from '../../shared/DialogShell';
import type { T } from './pluginModel';
/**
* How plugins are contained and where that ends, opened from the shield in the panel's
* header. It used to sit under the list as a card of its own, which pushed a wall of
* text into the page every admin scrolls past.
*/
export default function PluginSecurityDialog({ t, onClose }: { t: T; onClose: () => void }) {
const sections: Array<[string, string]> = [
[t('admin.plugins.security.isolationTitle'), t('admin.plugins.security.isolationBody')],
[t('admin.plugins.security.permsTitle'), t('admin.plugins.security.permsBody')],
[t('admin.plugins.security.limitsTitle'), t('admin.plugins.security.limitsBody')],
[t('admin.plugins.security.worstTitle'), t('admin.plugins.security.worstBody')],
[t('admin.plugins.security.reviewedTitle'), t('admin.plugins.security.reviewedBody')],
[t('admin.plugins.security.signedTitle'), t('admin.plugins.security.signedBody')],
[t('admin.plugins.security.trustTitle'), t('admin.plugins.security.trustBody')],
];
return (
<DialogShell
onClose={onClose}
labelledBy="plugin-security-title"
width="detail"
header={
<DialogHeader
tile={
<DialogTile>
<ShieldCheck size={20} className="text-content-secondary" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId="plugin-security-title"
title={t('admin.plugins.security.short')}
sub={t('admin.plugins.reviewedMeaning')}
subWraps
onClose={onClose}
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton variant="primary" onClick={onClose}>
{t('common.ok')}
</DialogButton>
</DialogFooter>
}
>
<div className="grid gap-2.5 sm:grid-cols-2">
{sections.map(([h, b]) => (
<div key={h} className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
<h4 className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>
{h}
</h4>
<p className="m-0 mt-1 leading-relaxed text-content-muted" style={fs(12, 'body')}>
{b}
</p>
</div>
))}
</div>
</DialogShell>
);
}
@@ -0,0 +1,41 @@
import { useEffect, useState, type CSSProperties } from 'react';
/** The tallest a row's ⋯ menu gets; below that it opens upwards instead. */
const ROW_MENU_MAX_H = 260;
/**
* Where a portaled menu sits: right-aligned under its trigger, or above it when the
* window has no room below. Fixed, because the cards around the toolbar and the rows
* clip whatever overflows them.
*/
export function anchorMenu(trigger: HTMLElement | null): CSSProperties {
const r = trigger?.getBoundingClientRect();
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 };
const spaceBelow = window.innerHeight - r.bottom;
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow;
return {
position: 'fixed',
right: Math.max(8, window.innerWidth - r.right),
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
zIndex: 100,
};
}
/** One entry of a popover menu, in the packing popovers' shape. */
export const MENU_ITEM =
'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary';
/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */
export function useFollowTrigger(open: boolean) {
const [, reanchor] = useState(0);
useEffect(() => {
if (!open) return;
const onMove = () => reanchor((n) => n + 1);
window.addEventListener('scroll', onMove, true);
window.addEventListener('resize', onMove);
return () => {
window.removeEventListener('scroll', onMove, true);
window.removeEventListener('resize', onMove);
};
}, [open]);
}
@@ -111,8 +111,14 @@ describe('pluginModel', () => {
blocked: false,
version: '0.9.0',
});
expect(installOffer(incompatible, t)).toMatchObject({ blocked: true, label: 'admin.plugins.incompatible' });
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, label: 'admin.plugins.installAnyway' });
// Nothing fits this TREK: still an Install, behind the admin's own warning for this plugin.
expect(installOffer(incompatible, t)).toMatchObject({
blocked: false,
label: 'admin.plugins.install',
warn: { acceptedByAdmin: true, trekRange: '>=9', hostVersion: '4.0.0' },
});
// TREK_PLUGINS_IGNORE_TREK_RANGE lets every plugin through, so the warning is not the admin's own choice.
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, warn: { acceptedByAdmin: false } });
});
it('FE-ADMIN-PLUGIN-MODEL-008: deriveDeps marks the blockers among the declared dependencies', () => {
@@ -298,7 +298,8 @@ export function installOffer(
title,
};
}
return { blocked: true, label: t('admin.plugins.incompatible'), title };
// No version fits this TREK: the admin may still install this one plugin at their own risk.
return bypassOffer(item, t, title, true);
}
/**
@@ -1,4 +1,4 @@
// FE-ADMIN-PLUGINS-HOOK-001 to -012: the plugin admin logic behind both admin shells.
// FE-ADMIN-PLUGINS-HOOK-001 to -014: the plugin admin logic behind both admin shells.
import { act, renderHook, waitFor } from '@testing-library/react';
import type { DragEvent } from 'react';
@@ -266,4 +266,36 @@ describe('usePluginsAdmin', () => {
expect(toast.success).toHaveBeenCalledWith('admin.plugins.uninstalled');
expect(result.current.confirmUninstall).toBeNull();
});
it('FE-ADMIN-PLUGINS-HOOK-013: switching on a plugin outside its TREK range asks first, then runs it anyway', async () => {
serve([
row({ enabled: 0, dependencyStatus: 'hostIncompatible', trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0' }),
]);
vi.spyOn(adminApi, 'pluginActivate').mockResolvedValue({});
const { result } = await mounted();
act(() => result.current.toggle(result.current.plugins[0] as PluginRow));
expect(adminApi.pluginActivate).not.toHaveBeenCalled();
expect(result.current.bypass.warning).toMatchObject({
acceptedByAdmin: true,
action: 'activate',
trekRange: '>=3.2.0 <4.0.0',
});
await act(async () => {
result.current.bypass.confirm();
});
await waitFor(() => expect(adminApi.pluginActivate).toHaveBeenCalledWith('alpha', false, true));
});
it('FE-ADMIN-PLUGINS-HOOK-014: installing behind the admin warning sends their consent for this plugin', async () => {
serve([], [entry({ id: 'beta', name: 'Beta' })]);
vi.spyOn(adminApi, 'pluginInstall').mockResolvedValue({ id: 'beta', version: '1.0.0', trekRangeBypassed: null });
const { result } = await mounted();
const warn = { name: 'Beta', trekRange: '<5.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: null };
act(() => result.current.install('beta', undefined, warn));
expect(adminApi.pluginInstall).not.toHaveBeenCalled();
await act(async () => {
result.current.bypass.confirm();
});
await waitFor(() => expect(adminApi.pluginInstall).toHaveBeenCalledWith('beta', { acceptTrekRange: true }));
});
});
@@ -317,9 +317,14 @@ export function usePluginsAdmin() {
act(
id,
() =>
adminApi.pluginInstall(id, version ? { version } : undefined).then((r) => {
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
}),
adminApi
.pluginInstall(id, {
...(version ? { version } : {}),
...(warn?.acceptedByAdmin ? { acceptTrekRange: true } : {}),
})
.then((r) => {
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
}),
t('admin.plugins.installed')
)
);
@@ -392,10 +397,10 @@ export function usePluginsAdmin() {
}
};
const attemptActivate = (p: PluginRow) => {
const attemptActivate = (p: PluginRow, acceptTrekRange = false) => {
const cascaded = autoEnabledDeps(p);
return adminApi
.pluginActivate(p.id)
.pluginActivate(p.id, false, acceptTrekRange)
.then(() => {
toast.success(t('admin.plugins.activated'));
if (cascaded.length)
@@ -415,12 +420,29 @@ export function usePluginsAdmin() {
void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated'));
return;
}
setBusy(p.id);
setMenu(null);
attemptActivate(p).finally(() => {
setBusy(null);
refresh();
});
const run = (acceptTrekRange: boolean) => {
setBusy(p.id);
setMenu(null);
attemptActivate(p, acceptTrekRange).finally(() => {
setBusy(null);
refresh();
});
};
// Outside the TREK versions its author declared: the admin may run it anyway, after
// reading what that risks. The choice holds for this version of the plugin only.
if (p.dependencyStatus === 'hostIncompatible') {
const warn: RangeWarning = {
name: p.name,
trekRange: p.trekRange ?? null,
hostVersion: p.hostVersion ?? '?',
acceptedByAdmin: true,
onConfirm: null,
action: 'activate',
};
bypass.guard(warn, () => run(true));
return;
}
run(false);
};
// Download a missing/outdated plugin dependency (latest compatible for its range,
@@ -16,15 +16,23 @@ describe('rangeBypass helpers', () => {
blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}',
})
expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' })
// An admin's own "run it anyway" for this one plugin says so, not "version checks off".
expect(bypassChip({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({
warn: true, label: 'admin.plugins.dep.trekAccepted:{"range":">=3.2.0 <4.0.0","host":"5.0.0"}',
})
expect(bypassChip({ trekRange: null, hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({
label: 'admin.plugins.dep.trekAcceptedUnknown',
})
})
it('bypassOffer: an enabled "Install anyway" carrying what the dialog must say', () => {
it('bypassOffer: an enabled Install that carries what the dialog must say first', () => {
const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
expect(offer).toEqual({
blocked: false, label: 'admin.plugins.installAnyway', title: 'why',
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null },
blocked: false, label: 'admin.plugins.install', title: 'why',
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false, onConfirm: null },
})
expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' })
expect(bypassOffer({ name: 'X' }, t, 'why', true).warn).toMatchObject({ acceptedByAdmin: true })
})
it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
@@ -32,10 +40,25 @@ describe('rangeBypass helpers', () => {
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
confirm: true, title: 'admin.plugins.rangeBypass.title',
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}',
confirmLabel: 'admin.plugins.installAnyway',
})
expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle',
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","host":"3.3.0"}',
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","range":null,"host":"3.3.0"}',
confirmLabel: 'admin.plugins.installAnyway',
})
})
it('rangeWarningCopy: an admin choosing for one plugin gets the short title, their own body and the right button', () => {
const mine = { name: 'G', trekRange: '>=4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: () => {} }
expect(rangeWarningCopy(mine, t)).toEqual({
confirm: true, title: 'admin.plugins.rangeBypass.titleAdmin',
body: 'admin.plugins.rangeBypass.bodyAdmin:{"name":"G","range":">=4.0.0","host":"5.0.0"}',
confirmLabel: 'admin.plugins.installAnyway',
})
expect(rangeWarningCopy({ ...mine, trekRange: null, action: 'activate' }, t)).toMatchObject({
body: 'admin.plugins.rangeBypass.bodyAdminUnknown:{"name":"G","range":null,"host":"5.0.0"}',
confirmLabel: 'admin.plugins.activateAnyway',
})
})
})
+61 -22
View File
@@ -4,10 +4,21 @@ import { useTranslation } from '../../i18n'
type T = (k: string, p?: Record<string, unknown>) => string
/** The server's "this only went through because TREK_PLUGINS_IGNORE_TREK_RANGE is set" marker. */
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string }
/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */
export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null }
/**
* The server's "this only went through because the version check was set aside" marker:
* by an admin for this one plugin (`acceptedByAdmin`), or for every plugin through
* TREK_PLUGINS_IGNORE_TREK_RANGE.
*/
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string; acceptedByAdmin?: boolean }
/**
* What the range-bypass warning needs to say, and what to do if the admin accepts (null = a
* notice). `action` names what the confirm button does: install (the default) or activate.
*/
export interface RangeWarning extends TrekRangeBypass {
name: string
onConfirm: (() => void) | null
action?: 'install' | 'activate'
}
/**
* The chip a row carries while it runs outside its declared range on the operator's
@@ -16,29 +27,42 @@ export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm:
*/
export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
if (!b) return null
const label = b.acceptedByAdmin
? b.trekRange
? t('admin.plugins.dep.trekAccepted', { range: b.trekRange, host: b.hostVersion })
: t('admin.plugins.dep.trekAcceptedUnknown')
: b.trekRange
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
: t('admin.plugins.dep.trekBypassedUnknown')
return {
icon: AlertTriangle,
label: b.trekRange
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
: t('admin.plugins.dep.trekBypassedUnknown'),
label,
blocked: false,
warn: true,
}
}
/**
* The Install button for a registry entry the server flagged incompatible, once the
* operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version
* regardless, so the button says exactly that — and carries what the warning has to say
* first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
* The Install button for a registry entry the server flagged incompatible: the server will
* take the newest version regardless, so the button says exactly that — and carries what
* the warning has to say first. `byAdmin` is the admin's own "install anyway" for this one
* plugin; without it the operator set TREK_PLUGINS_IGNORE_TREK_RANGE for every plugin.
* `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
*/
export function bypassOffer(item: { name: string; trek?: string | null; hostVersion?: string }, t: T, title: string) {
return {
blocked: false,
label: t('admin.plugins.installAnyway'),
title,
warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning,
export function bypassOffer(
item: { name: string; trek?: string | null; hostVersion?: string },
t: T,
title: string,
byAdmin = false
) {
const warn: RangeWarning = {
name: item.name,
trekRange: item.trek ?? null,
hostVersion: item.hostVersion ?? '?',
acceptedByAdmin: byAdmin,
onConfirm: null,
}
return { blocked: false, label: t('admin.plugins.install'), title, warn }
}
/**
@@ -48,14 +72,29 @@ export function bypassOffer(item: { name: string; trek?: string | null; hostVers
* whole point: the admin is accepting that the author never vouched for this TREK and
* that, in rare cases, that can cost them data.
*/
export function rangeWarningCopy(w: RangeWarning, t: T): { title: string; body: string; confirm: boolean } {
export function rangeWarningCopy(
w: RangeWarning,
t: T
): { title: string; body: string; confirm: boolean; confirmLabel: string } {
const confirm = w.onConfirm !== null
const vars = { name: w.name, range: w.trekRange, host: w.hostVersion }
const body = w.acceptedByAdmin
? w.trekRange
? t('admin.plugins.rangeBypass.bodyAdmin', vars)
: t('admin.plugins.rangeBypass.bodyAdminUnknown', vars)
: w.trekRange
? t('admin.plugins.rangeBypass.body', vars)
: t('admin.plugins.rangeBypass.bodyUnknown', vars)
const title = w.acceptedByAdmin
? t('admin.plugins.rangeBypass.titleAdmin')
: confirm
? t('admin.plugins.rangeBypass.title')
: t('admin.plugins.rangeBypass.noticeTitle')
return {
confirm,
title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'),
body: w.trekRange
? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion })
: t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }),
title,
body,
confirmLabel: w.action === 'activate' ? t('admin.plugins.activateAnyway') : t('admin.plugins.installAnyway'),
}
}
@@ -426,6 +426,23 @@ describe('calculateRouteWithLegs', () => {
expect(second).toBe(first)
})
it('FE-COMP-ROUTECALCULATOR-035b: a route answered once is still there after a reload, without asking the router (#2794)', async () => {
let hits = 0
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => { hits++; return HttpResponse.json(buildLegsResponse()) }))
const wps = freshWaypoints()
const first = await calculateRouteWithLegs(wps)
const { offlineDb } = await import('../../db/offlineDb')
await vi.waitFor(async () => expect(await offlineDb.routeCache.count()).toBeGreaterThan(0))
// A reload: the module, and with it the cache in memory, starts over.
vi.resetModules()
const reloaded = await import('./RouteCalculator')
const again = await reloaded.calculateRouteWithLegs(wps)
expect(hits).toBe(1)
expect(again).toEqual(first)
})
it('FE-COMP-ROUTECALCULATOR-036: switching the distance unit re-fetches instead of reusing stale text (#1300)', async () => {
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(buildLegsResponse())))
const wps = freshWaypoints()
+7 -20
View File
@@ -4,6 +4,7 @@ import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWay
import { haversineKm } from '../../utils/geo'
import { formatDistance } from '../../utils/units'
import { countRoute } from './routeUsageCounter'
import { cacheRoute, rememberRoute, routeInMemory, storedRoute } from './routeStore'
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
import { furthestFrom, sameRoad, type RouteUsageSurface } from '@trek/shared'
@@ -196,10 +197,6 @@ function retryAfterMs(response: Response): number | null {
return Number.isNaN(at) ? null : Math.max(0, at - Date.now())
}
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
const routeCache = new Map<string, RouteWithLegs>()
const ROUTE_CACHE_MAX = 200
/**
* A route profile is either one of the built-in OSRM profiles or a plugin profile
@@ -540,7 +537,10 @@ export async function calculateRouteWithLegs(
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
const plainKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
const cacheKey = `${plainKey}${avoidScope}`
const cached = routeCache.get(cacheKey)
// A plugin route stays in memory: its answer depends on the trip and the day,
// and may carry live data such as charging stops. The rest is kept on the device
// too, so a trip that shows its whole route opens drawn (#2794).
const cached = pluginScope ? routeInMemory(cacheKey) : await storedRoute(cacheKey)
if (cached) return cached
if (avoiding.length) {
@@ -552,7 +552,7 @@ export async function calculateRouteWithLegs(
// Null is a Valhalla that could not answer at all. OSRM keeps the day drawn rather
// than blank, and the answer says that nothing was avoided, with the reason, so the
// rail flags the day instead of implying the setting held on a road nobody weighed.
const plain = routeCache.get(plainKey) ?? await osrmWithLegs(waypoints, coords, profile, signal)
const plain = (await storedRoute(plainKey)) ?? await osrmWithLegs(waypoints, coords, profile, signal)
cacheRoute(plainKey, plain)
return { ...plain, avoidance: { asked: avoiding, achieved: [], fellBack: true } }
}
@@ -591,11 +591,7 @@ export async function calculateRouteWithLegs(
legs,
...(route.viaPoints.length ? { vias: route.viaPoints } : {}),
}
routeCache.set(cacheKey, result)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
if (oldest !== undefined) routeCache.delete(oldest)
}
rememberRoute(cacheKey, result)
return result
}
@@ -638,15 +634,6 @@ async function osrmWithLegs(
return { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
}
/** Store a route and drop the oldest once the cache is over its cap. */
function cacheRoute(key: string, route: RouteWithLegs): void {
routeCache.set(key, route)
if (routeCache.size > ROUTE_CACHE_MAX) {
const oldest = routeCache.keys().next().value
if (oldest !== undefined) routeCache.delete(oldest)
}
}
/**
* A whole chain from the second engine, weighted away from some road classes.
*
+41
View File
@@ -0,0 +1,41 @@
import { routeCacheRepo } from '../../repo/routeCacheRepo';
import type { RouteWithLegs } from '../../types';
/**
* Route answers by RouteCalculator's cache key (profile, unit, waypoints,
* avoided roads). Routes are stable, so this keeps the router from being asked
* again on every day switch or reorder: in memory for this visit, and on the
* device for the next one, so a trip that shows its whole route opens drawn
* instead of routing every leg again, one a second (#2794).
*/
const inMemory = new Map<string, RouteWithLegs>();
const MEMORY_MAX = 200;
/** Keeps a route for this visit only, dropping the oldest past the limit. */
export function rememberRoute(key: string, route: RouteWithLegs): void {
inMemory.set(key, route);
if (inMemory.size > MEMORY_MAX) {
const oldest = inMemory.keys().next().value;
if (oldest !== undefined) inMemory.delete(oldest);
}
}
/** The route this visit already holds, without asking the device. */
export function routeInMemory(key: string): RouteWithLegs | undefined {
return inMemory.get(key);
}
/** Keeps a route the router just answered, in memory and on the device. */
export function cacheRoute(key: string, route: RouteWithLegs): void {
rememberRoute(key, route);
void routeCacheRepo.put(key, route);
}
/** The route kept in memory, else the one kept on the device, which then stays in memory too. */
export async function storedRoute(key: string): Promise<RouteWithLegs | null> {
const held = inMemory.get(key);
if (held) return held;
const kept = await routeCacheRepo.get<RouteWithLegs>(key);
if (kept) rememberRoute(key, kept);
return kept;
}
@@ -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 ? (
@@ -5,19 +5,22 @@ import { useLocationBias } from '../../hooks/useLocationBias'
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage'
import { useSuggestionDropdown } from './useSuggestionDropdown'
import { PlaceSuggestion, SuggestionList, SuggestionRow } from './SuggestionDropdown'
import type { MapMatch } from '../../repo/mapMatchRepo'
interface Props {
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
/** A picked suggestion's spot on the map, for a form that saves where the address is (an imported stay). */
onPick?: (match: MapMatch) => void
}
// Free-text address input with location autocomplete, backed by the same maps
// search as LocationSelect. Unlike LocationSelect the typed text is
// authoritative: every keystroke reaches the parent so a hand-written address
// is never lost, and picking a suggestion just replaces the text (#1496).
export default function AddressInput({ value, onChange, placeholder, className }: Props) {
export default function AddressInput({ value, onChange, placeholder, className, onPick }: Props) {
const { locale } = useTranslation()
const placeLang = usePlaceLanguage()
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
@@ -60,6 +63,9 @@ export default function AddressInput({ value, onChange, placeholder, className }
// A response still on its way must not repopulate the list behind the pick.
reqIdRef.current++
onChange(r.address || r.name || '')
if (onPick && Number.isFinite(r.lat) && Number.isFinite(r.lng)) {
onPick({ lat: r.lat, lng: r.lng, label: [r.name, r.address].filter(Boolean).join(', ') })
}
setOpen(false)
setResults([])
setLoading(false)
@@ -29,6 +29,7 @@ import { useReservationForm } from './useReservationForm'
import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, filesNeedConnection, uploadBookingFiles } from './bookingFormModel'
import { useBookingExpenseIntent } from './useBookingExpenseIntent'
import { useBookingFileAttach } from './useBookingFileAttach'
import StayPinStatus from './StayPinStatus'
const EMPTY_FORM = {
title: '', type: 'other', status: 'pending',
@@ -81,7 +82,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const {
form, setForm, set, isSaving, setIsSaving, pendingFiles, setPendingFiles, travelerIds, setTravelerIds, toggleTraveler,
isEndBeforeStart, endTimeBeforeStartTime, dateBounds: tripDateRange, startDate, startTime, setStartDate, setStartTime, takeStopDay,
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, saveData: buildSaveData,
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, stayPin, saveData: buildSaveData,
} = useReservationForm(EMPTY_FORM, days, places)
const attach = useBookingFileAttach({ reservation, files, onFileUpload, setPendingFiles, toast, t })
@@ -102,17 +103,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
}
setTravelerIds(travelerIdsOf(reservation))
stayPin.reset()
if (reservation) {
setForm({ ...reservationFieldsFrom(reservation, accommodations, places, true), assignment_id: reservation.assignment_id || '' })
} else if (prefill) {
// Review-before-save: populate from a parsed import item, stay in create mode.
const hotelPlaceId = matchPlaceId(prefill._venue?.name || prefill.title)
setForm({
...reservationFieldsFromPrefill(prefill, days, true),
assignment_id: defaultAssignmentId ?? '',
accommodation_id: '',
place_id: '',
hotel_place_id: matchPlaceId(prefill._venue?.name || prefill.title),
hotel_place_id: hotelPlaceId,
})
// A stay the trip does not hold yet: find it on the map now, so the review
// says whether it will show there before it is saved.
if (prefill.type === 'hotel' && !hotelPlaceId) stayPin.lookUp(prefill._venue?.name || prefill.title, prefill._venue?.address)
// Seed the booking's Files with the document this item was parsed from.
setPendingFiles(prefill._sourceFiles ?? [])
} else {
@@ -362,8 +368,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
<EditorField label={t('reservations.locationAddress')}>
<AddressInput value={form.hotel_address} onChange={v => set('hotel_address', v)}
onPick={stayPin.pick}
placeholder={t('reservations.locationPlaceholder')} className={INPUT} />
</EditorField>
<StayPinStatus
pin={stayPin.pin}
hidden={!!form.hotel_place_id}
onRetry={() => stayPin.lookUp(form.title, form.hotel_address)}
/>
</>
)}
@@ -0,0 +1,54 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { createElement } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { TranslationProvider } from '../../i18n/TranslationContext';
import StayPinStatus from './StayPinStatus';
import type { StayPin } from './useStayPin';
function show(pin: StayPin, onRetry = vi.fn()) {
render(createElement(TranslationProvider, null, createElement(StayPinStatus, { pin, onRetry })));
return onRetry;
}
describe('StayPinStatus', () => {
it('FE-STAY-PIN-STATUS-001: shows nothing before a lookup', () => {
const { container } = render(
createElement(
TranslationProvider,
null,
createElement(StayPinStatus, { pin: { status: 'idle' }, onRetry: vi.fn() })
)
);
expect(container.textContent).toBe('');
});
it('FE-STAY-PIN-STATUS-001b: a stay already linked to a place of the trip shows nothing', () => {
const { container } = render(
createElement(
TranslationProvider,
null,
createElement(StayPinStatus, { pin: { status: 'missing' }, hidden: true, onRetry: vi.fn() })
)
);
expect(container.textContent).toBe('');
});
it('FE-STAY-PIN-STATUS-002: says it is looking, then where it found the stay', () => {
show({ status: 'searching' });
expect(screen.getByRole('status')).toHaveTextContent('Looking for it on the map');
});
it('FE-STAY-PIN-STATUS-003: a found stay names the match, so a wrong one is plain', () => {
show({ status: 'found', match: { lat: 1, lng: 2, label: 'Hotel Gracery Shinjuku, Tokyo' } });
expect(screen.getByRole('status')).toHaveTextContent('On the map');
expect(screen.getByText('Hotel Gracery Shinjuku, Tokyo')).toBeInTheDocument();
});
it('FE-STAY-PIN-STATUS-004: a missing stay says so and offers to search again', () => {
const onRetry = show({ status: 'missing' });
expect(screen.getByText('Not on the map yet')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /search again/i }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,62 @@
import { MapPin, MapPinOff, RotateCw } from 'lucide-react';
import { useTranslation } from '../../i18n';
import type { StayPin } from './useStayPin';
interface StayPinStatusProps {
pin: StayPin;
/** A stay already linked to a place of the trip shows on the map through that place. */
hidden?: boolean;
/** Asks the map again with what the form now holds. */
onRetry: () => void;
}
/**
* Whether an imported stay will show on the map, under its address in the
* review: being looked up, found (and where, so a wrong match is plain), or not
* found, with a way to search again before it is saved without a pin. Shared by
* the desktop booking dialog and the phone's booking sheet.
*/
export default function StayPinStatus({ pin, hidden = false, onRetry }: StayPinStatusProps) {
const { t } = useTranslation();
if (hidden || pin.status === 'idle') return null;
if (pin.status === 'searching') {
return (
<div role="status" className="flex items-center gap-2 px-1 text-caption text-content-muted">
<span className="h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-edge border-t-content-muted" />
{t('reservations.import.pinSearching')}
</div>
);
}
if (pin.status === 'found') {
return (
<div role="status" className="flex min-w-0 items-center gap-2 rounded-lg bg-success-soft px-3 py-2 text-caption">
<MapPin size={14} strokeWidth={2.2} className="flex-none text-success" />
<span className="flex-none font-semibold text-success">{t('reservations.import.pinFound')}</span>
<span className="min-w-0 truncate text-content-secondary" title={pin.match.label}>
{pin.match.label}
</span>
</div>
);
}
return (
<div role="status" className="flex items-start gap-3 rounded-xl bg-warning-soft px-3.5 py-3">
<MapPinOff size={16} strokeWidth={2.2} className="mt-0.5 flex-none text-warning" />
<div className="min-w-0 flex-1">
<p className="m-0 text-body font-semibold text-content">{t('reservations.import.pinMissingTitle')}</p>
<p className="m-0 mt-0.5 text-caption text-content-muted">{t('reservations.import.pinMissingBody')}</p>
<button
type="button"
onClick={onRetry}
className="mt-2 inline-flex items-center gap-1.5 rounded-lg border border-edge bg-surface-card px-2.5 py-1 text-caption font-medium text-content hover:bg-surface-hover"
>
<RotateCw size={12} strokeWidth={2.2} />
{t('reservations.import.pinRetry')}
</button>
</div>
</div>
);
}
@@ -287,6 +287,8 @@ export interface ReservationSaveOptions {
isEdit: boolean;
/** The desktop also saves the end of the check-in window. */
withCheckInEnd: boolean;
/** Where the review found an imported stay on the map, so the save creates its place right there. */
venuePin?: { lat: number; lng: number } | null;
}
export type ReservationSaveData = Record<string, unknown> & { title: string };
@@ -344,7 +346,11 @@ export function reservationSaveData(
// import): the save handler geocodes it and creates the place.
venue:
!form.hotel_place_id && (form.hotel_address || form.title)
? { name: form.title, address: form.hotel_address || null }
? {
name: form.title,
address: form.hotel_address || null,
...(opts.venuePin ? { lat: opts.venuePin.lat, lng: opts.venuePin.lng } : {}),
}
: null,
// The typed address, so the save handler can write it through to a linked
// place; an edited address used to be silently dropped (#1496).
@@ -19,6 +19,7 @@ import {
withStartDate,
withStartTime,
} from './reservationFormModel';
import { useStayPin } from './useStayPin';
/**
* The booking form behind the desktop ReservationModal and the phone's booking
@@ -39,6 +40,8 @@ export function useReservationForm<F extends ReservationFields & CheckInEndField
const set = (field: keyof F, value: string | number) => setForm((prev) => ({ ...prev, [field]: value }));
const toggleTraveler = (id: number) => setTravelerIds((prev) => toggledTraveler(prev, id));
// Where an imported stay will land on the map, looked up while it is reviewed.
const stayPin = useStayPin();
// Restrict non-hotel booking dates to the trip's span (#1662). Hotels already
// constrain to trip days via their day dropdowns.
@@ -71,6 +74,7 @@ export function useReservationForm<F extends ReservationFields & CheckInEndField
pickHotelPlace: (value: string | number) => setForm((prev) => withHotelPlace(prev, value, places)),
pickHotelStart: (value: string | number) => setForm((prev) => withHotelStart(prev, value, days)),
pickHotelEnd: (value: string | number) => setForm((prev) => withHotelEnd(prev, value, days)),
saveData: (opts: ReservationSaveOptions) => reservationSaveData(form, opts),
stayPin,
saveData: (opts: ReservationSaveOptions) => reservationSaveData(form, { ...opts, venuePin: stayPin.match }),
};
}
@@ -0,0 +1,74 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { findOnMap } from '../../repo/mapMatchRepo';
import { isEffectivelyOffline } from '../../sync/networkMode';
import { useStayPin } from './useStayPin';
vi.mock('../../hooks/usePlaceLanguage', () => ({ usePlaceLanguage: () => 'en' }));
vi.mock('../../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
vi.mock('../../repo/mapMatchRepo', async (importOriginal) => ({
...(await importOriginal<typeof import('../../repo/mapMatchRepo')>()),
findOnMap: vi.fn(),
}));
const SPOT = { lat: 35.69, lng: 139.7, label: 'Hotel Gracery Shinjuku' };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
});
describe('useStayPin', () => {
it('FE-STAY-PIN-001: looks the stay up by name and address and says where it found it', async () => {
vi.mocked(findOnMap).mockResolvedValueOnce(SPOT);
const { result } = renderHook(() => useStayPin());
expect(result.current.pin).toEqual({ status: 'idle' });
act(() => result.current.lookUp('Hotel Gracery', '1-19-1 Kabukicho'));
expect(result.current.pin).toEqual({ status: 'searching' });
await waitFor(() => expect(result.current.pin).toEqual({ status: 'found', match: SPOT }));
expect(result.current.match).toEqual(SPOT);
expect(findOnMap).toHaveBeenCalledWith(
['Hotel Gracery 1-19-1 Kabukicho', 'Hotel Gracery', '1-19-1 Kabukicho'],
'en'
);
});
it('FE-STAY-PIN-002: nothing found, a failed search and an empty name all read as not on the map', async () => {
const { result } = renderHook(() => useStayPin());
vi.mocked(findOnMap).mockResolvedValueOnce(null);
act(() => result.current.lookUp('Ryokan', null));
await waitFor(() => expect(result.current.pin).toEqual({ status: 'missing' }));
expect(result.current.match).toBeNull();
vi.mocked(findOnMap).mockRejectedValueOnce(new Error('down'));
act(() => result.current.lookUp('Ryokan', null));
await waitFor(() => expect(result.current.pin).toEqual({ status: 'missing' }));
act(() => result.current.lookUp('', ''));
expect(result.current.pin).toEqual({ status: 'missing' });
});
it('FE-STAY-PIN-003: a later lookup or a hand-picked spot wins over an answer still on its way', async () => {
let answer: (m: typeof SPOT | null) => void = () => undefined;
vi.mocked(findOnMap).mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)));
const { result } = renderHook(() => useStayPin());
act(() => result.current.lookUp('Hotel', 'Somewhere'));
const picked = { lat: 1, lng: 2, label: 'Picked' };
act(() => result.current.pick(picked));
await act(async () => answer(SPOT));
expect(result.current.pin).toEqual({ status: 'found', match: picked });
act(() => result.current.reset());
expect(result.current.pin).toEqual({ status: 'idle' });
});
it('FE-STAY-PIN-004: offline there is nothing to ask, so the review shows nothing', () => {
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
const { result } = renderHook(() => useStayPin());
act(() => result.current.lookUp('Hotel', 'Somewhere'));
expect(result.current.pin).toEqual({ status: 'idle' });
expect(findOnMap).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from 'react';
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage';
import { findOnMap, type MapMatch, stayQueries } from '../../repo/mapMatchRepo';
import { isEffectivelyOffline } from '../../sync/networkMode';
/**
* Where an imported stay will sit on the map, as the review shows it: not asked
* yet (or offline, where there is nothing to ask), being looked up, found, or
* not found at all.
*/
export type StayPin =
{ status: 'idle' } | { status: 'searching' } | { status: 'found'; match: MapMatch } | { status: 'missing' };
/**
* The map spot of a stay reviewed from an import, looked up before it is saved
* so the review can say whether it will show on the map. A stay the search did
* not find used to be saved without coordinates and simply never appeared.
* A later lookup always wins over an earlier one still on its way.
*/
export function useStayPin() {
const lang = usePlaceLanguage();
const [pin, setPin] = useState<StayPin>({ status: 'idle' });
const ticket = useRef(0);
useEffect(
() => () => {
ticket.current++;
},
[]
);
const lookUp = (name: string | null | undefined, address: string | null | undefined) => {
const mine = ++ticket.current;
const queries = stayQueries(name ?? '', address);
if (isEffectivelyOffline()) {
setPin({ status: 'idle' });
return;
}
if (queries.length === 0) {
setPin({ status: 'missing' });
return;
}
setPin({ status: 'searching' });
findOnMap(queries, lang).then(
(match) => {
if (mine === ticket.current) setPin(match ? { status: 'found', match } : { status: 'missing' });
},
() => {
if (mine === ticket.current) setPin({ status: 'missing' });
}
);
};
return {
pin,
/** The spot to save the stay at, once one is known. */
match: pin.status === 'found' ? pin.match : null,
lookUp,
/** A spot the reader picked by hand, which beats any lookup. */
pick: (match: MapMatch) => {
ticket.current++;
setPin({ status: 'found', match });
},
reset: () => {
ticket.current++;
setPin({ status: 'idle' });
},
};
}
@@ -1,4 +1,4 @@
// FE-AI-CONNECT-MODEL-001 to FE-AI-CONNECT-MODEL-007: the pure rules behind connecting an AI provider.
// FE-AI-CONNECT-MODEL-001 to FE-AI-CONNECT-MODEL-008: the pure rules behind connecting an AI provider.
import type { AiConnectionView } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
@@ -6,6 +6,7 @@ import {
connectErrorKey,
httpHostOf,
isPublicHttpsUrl,
openrouterReturnCode,
refusalCode,
rememberOpenrouterFlow,
repairModeOf,
@@ -46,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');
@@ -93,4 +102,14 @@ describe('aiConnectModel', () => {
expect(src).not.toContain('<');
expect(decodeURIComponent(src.split(',')[1])).toBe('<svg><path d="M0 0"/></svg>');
});
it('FE-AI-CONNECT-MODEL-008: reads the OpenRouter return with the code after an & or after a second ?', () => {
const read = (query: string) => openrouterReturnCode(new URLSearchParams(query));
expect(read('tab=integrations&ai=openrouter&code=abc')).toBe('abc');
expect(read('tab=integrations&ai=openrouter?code=abc')).toBe('abc');
expect(read('tab=addons&ai=openrouter?code=abc&state=x')).toBe('abc');
expect(read('ai=openrouter')).toBeNull();
expect(read('tab=integrations')).toBeUndefined();
expect(read('ai=chatgpt&code=abc')).toBeUndefined();
});
});
@@ -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. */
@@ -56,6 +67,26 @@ export function repairModeOf(view: AiConnectionView): AiConnectMode | null {
return view.kind === 'custom' ? 'custom' : 'key';
}
/**
* Whether a pasted ChatGPT callback ended this sign-in rather than an earlier
* one: its `state` is the one the sign-in address carried. The server takes a
* sign-in only once, so an address from an earlier attempt still on the
* clipboard would use up the one in progress. Without a state on either side
* the server decides.
*/
export function callbackMatchesFlow(callback: string, authorizeUrl: string): boolean {
const stateOf = (address: string) => {
try {
return new URL(address.trim()).searchParams.get('state');
} catch {
return null;
}
};
const expected = stateOf(authorizeUrl);
const pasted = stateOf(callback);
return !expected || !pasted || pasted === expected;
}
// The OpenRouter sign-in leaves the page and comes back with a code only; the
// flow it belongs to waits here for that one return. Tab-scoped on purpose.
const OPENROUTER_FLOW_KEY = 'trek.ai.openrouterFlow';
@@ -79,6 +110,21 @@ export function takeOpenrouterFlow(): string | null {
}
}
/**
* The code an OpenRouter sign-in came back with, or `undefined` when the page
* was not opened by one. The return address already carries a query
* (`?tab=…&ai=openrouter`), and OpenRouter may add its code with a second `?`
* rather than an `&`, which leaves it inside the `ai` value
* (`ai=openrouter?code=…`); both shapes count.
*/
export function openrouterReturnCode(params: URLSearchParams): string | null | undefined {
const ai = params.get('ai');
if (ai === null) return undefined;
const [marker, rest] = ai.split('?', 2);
if (marker !== 'openrouter') return undefined;
return params.get('code') ?? new URLSearchParams(rest ?? '').get('code');
}
const PRIVATE_V4 = [/^10\./, /^127\./, /^169\.254\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./, /^0\./];
/**
@@ -1,4 +1,4 @@
// FE-HOOK-AI-CONNECT-001 to FE-HOOK-AI-CONNECT-013: pasting a key, an own server, the ChatGPT sign-in and the OpenRouter return.
// FE-HOOK-AI-CONNECT-001 to FE-HOOK-AI-CONNECT-015: pasting a key, an own server, the ChatGPT sign-in and the OpenRouter return.
import { act, fireEvent, renderHook, waitFor } from '@testing-library/react';
import { createElement, type ReactNode } from 'react';
import { MemoryRouter, useLocation } from 'react-router';
@@ -249,6 +249,52 @@ describe('useChatgptConnect', () => {
expect(readText).toHaveBeenCalledTimes(1);
input.remove();
});
it('FE-HOOK-AI-CONNECT-014: the same address handed in twice at once is sent once, so the sign-in is not used up', async () => {
let answer: (value: unknown) => void = () => undefined;
repo.chatgptFinish.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)));
const onConnected = vi.fn();
const { result } = renderHook(() => useChatgptConnect({ active: true, onConnected }), { wrapper: wrapperAt() });
await waitFor(() => expect(result.current.flow).not.toBeNull());
act(() => {
result.current.paste(CALLBACK);
result.current.paste(CALLBACK);
});
await act(async () => answer({}));
await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1));
expect(repo.chatgptFinish).toHaveBeenCalledTimes(1);
expect(result.current.error).toBeNull();
});
it('FE-HOOK-AI-CONNECT-015: an address from an earlier sign-in is not sent, and the one in progress stays usable', async () => {
repo.chatgptStart.mockResolvedValue({ ...FLOW, authorizeUrl: 'https://auth.openai.com/oauth/authorize?state=s2' });
const readText = vi.fn(async () => CALLBACK);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { readText } });
Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true });
const onConnected = vi.fn();
const { result } = renderHook(() => useChatgptConnect({ active: true, onConnected }), { wrapper: wrapperAt() });
await waitFor(() => expect(result.current.flow).not.toBeNull());
// Back from the sign-in window with the old address still on the clipboard: nothing is sent.
act(() => result.current.markOpened());
fireEvent.focus(window);
await waitFor(() => expect(readText).toHaveBeenCalled());
expect(repo.chatgptFinish).not.toHaveBeenCalled();
expect(result.current.error).toBeNull();
// Pasted by hand it is refused here, and the address this sign-in ended on still goes through.
act(() => {
result.current.paste(CALLBACK);
});
expect(result.current.error).toEqual({ messageKey: 'assistant.connect.chatgptWrong', code: 'wrong_url' });
const current = CALLBACK.replace('state=s1', 'state=s2');
act(() => {
result.current.paste(current);
});
await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1));
expect(repo.chatgptFinish).toHaveBeenCalledWith({ flowId: FLOW.flowId, pastedUrl: current });
expect(repo.chatgptStart).toHaveBeenCalledTimes(1);
});
});
describe('OpenRouter', () => {
@@ -280,4 +326,19 @@ describe('OpenRouter', () => {
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
expect(repo.openrouterFinish).not.toHaveBeenCalled();
});
it('FE-HOOK-AI-CONNECT-012b: a code OpenRouter added with a second question mark still finishes the sign-in', async () => {
rememberOpenrouterFlow(FLOW.flowId);
const onConnected = vi.fn();
const { result } = renderHook(
() => {
useOpenrouterReturn(onConnected);
return useLocation();
},
{ wrapper: wrapperAt('/settings?tab=integrations&ai=openrouter?code=or-code') }
);
await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1));
expect(repo.openrouterFinish).toHaveBeenCalledWith({ flowId: FLOW.flowId, code: 'or-code' });
expect(result.current.search).toBe('?tab=integrations');
});
});
@@ -5,7 +5,15 @@ import { useTranslation } from '../../../i18n';
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
import { detectKeyKind, isChatgptCallback, providerLabel } from '../../Assistant/providerCatalog';
import { useToast } from '../../shared/Toast';
import { connectErrorKey, httpHostOf, refusalCode, rememberOpenrouterFlow, takeOpenrouterFlow } from './aiConnectModel';
import {
callbackMatchesFlow,
connectErrorKey,
httpHostOf,
openrouterReturnCode,
refusalCode,
rememberOpenrouterFlow,
takeOpenrouterFlow,
} from './aiConnectModel';
/**
* The ways to connect a provider, one hook each, behind `AiConnectPanel`: a
@@ -205,6 +213,10 @@ export function useChatgptConnect({
const [busy, setBusy] = useState(false);
const [error, setError] = useState<ConnectError | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Set at once, unlike `busy`: the clipboard read on return and the user's own
// paste can hand in the same address within one render, and the server takes
// a sign-in only once, so the second would come back as expired.
const finishing = useRef(false);
useEffect(() => {
if (!active) return;
@@ -232,8 +244,8 @@ export function useChatgptConnect({
const finish = async (raw: string) => {
const url = raw.trim();
if (!flow || busy) return;
if (!isChatgptCallback(url)) {
if (!flow || finishing.current) return;
if (!isChatgptCallback(url) || !callbackMatchesFlow(url, flow.authorizeUrl)) {
setError({ messageKey: 'assistant.connect.chatgptWrong', code: 'wrong_url' });
return;
}
@@ -242,6 +254,7 @@ export function useChatgptConnect({
restart();
return;
}
finishing.current = true;
setBusy(true);
setError(null);
try {
@@ -253,6 +266,7 @@ export function useChatgptConnect({
setError({ messageKey: connectErrorKey(err), code });
if (code === 'expired') restart();
} finally {
finishing.current = false;
setBusy(false);
}
};
@@ -280,10 +294,12 @@ export function useChatgptConnect({
// Back from the sign-in window: the address the user just copied is taken
// off the clipboard where the browser allows it, else the field has the cursor.
// An address left over from an earlier sign-in is not sent: it would use up this one.
useOnReturnToTab(opened, () => {
if (pasted.trim() || busy) return;
readClipboard((text) => {
if (!paste(text)) focusField();
if (flow && !callbackMatchesFlow(text, flow.authorizeUrl)) focusField();
else if (!paste(text)) focusField();
}, focusField);
});
@@ -351,9 +367,9 @@ export function useOpenrouterReturn(onConnected: () => void) {
});
useEffect(() => {
if (handled.current || params.get('ai') !== 'openrouter') return;
const code = openrouterReturnCode(params);
if (handled.current || code === undefined) return;
handled.current = true;
const code = params.get('code');
setParams(
(prev) => {
const next = new URLSearchParams(prev);
@@ -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>
+19
View File
@@ -70,6 +70,19 @@ interface CachedTourDraft {
draft: TourDraft;
}
/**
* A road route as the router answered it, kept on the device so a trip opens
* with its roads drawn instead of asking the router for every leg again (#2794).
* `key` is RouteCalculator's cache key (profile, unit, waypoints, avoided
* roads), `route` its answer as it came, `savedAt` when it came: an old answer
* is asked again, and the oldest give way first.
*/
interface CachedRoute {
key: string;
route: unknown;
savedAt: number;
}
// ── Queue + sync types ────────────────────────────────────────────────────────
// 'conflict' is terminal-until-resolved: the server rejected the replay because
@@ -279,6 +292,7 @@ export class TrekOfflineDb extends Dexie {
ferrataTiles!: Table<CachedFerrataTile, [number, string]>;
tourStationVisits!: Table<CachedTourStationVisit, [number, string]>;
tourDrafts!: Table<CachedTourDraft, number>;
routeCache!: Table<CachedRoute, string>;
constructor(name: string = ANON_DB_NAME) {
super(name);
@@ -366,6 +380,11 @@ export class TrekOfflineDb extends Dexie {
// v13: the Tours planner's unsaved draft as the account last said it, one
// per trip (the database is the signed-in user's own), so it opens offline.
this.version(13).stores({ tourDrafts: 'tripId' });
// v14: road routes the router answered, by their cache key, so a trip with its
// whole route shown opens drawn instead of re-routing every leg (#2794). Not per
// trip: the same waypoints are the same road in any trip; `savedAt` to prune.
this.version(14).stores({ routeCache: 'key, savedAt' });
}
}
@@ -294,7 +294,7 @@ export default function MAdminPluginsPanel() {
{bypass.copy && (
<MConfirmSheet open onClose={bypass.dismiss} onConfirm={bypass.copy.confirm ? bypass.confirm : undefined}
title={bypass.copy.title} message={bypass.copy.body}
confirmLabel={t('admin.plugins.installAnyway')} cancelLabel={bypass.copy.confirm ? t('common.cancel') : t('common.ok')} danger />
confirmLabel={bypass.copy.confirmLabel} cancelLabel={bypass.copy.confirm ? t('common.cancel') : t('common.ok')} danger />
)}
{/* Row ⋯ action sheet */}
@@ -50,13 +50,17 @@ export default function MAssistantHomeScreen() {
useAssistantStatus();
if (inTrip || !available || !open || minimized) return null;
// Mounted beside MobileShell, not inside it, so the --m-* tokens the screen paints
// with only resolve under an m-root of its own; without it the chat is see-through.
return (
<AssistantScreen
tripId={chat.id}
tripTitle={chat.title}
hidden={false}
hops={hops}
onOpenTripChat={journal ? undefined : home.openTripChat}
/>
<div className="m-root">
<AssistantScreen
tripId={chat.id}
tripTitle={chat.title}
hidden={false}
hops={hops}
onOpenTripChat={journal ? undefined : home.openTripChat}
/>
</div>
);
}
@@ -15,6 +15,7 @@ import {
RESERVATION_TYPE_OPTIONS as TYPE_OPTIONS, reservationFieldsFrom, reservationFieldsFromPrefill, selectedDayDate,
} from '../../../../components/Planner/reservationFormModel'
import { useReservationForm } from '../../../../components/Planner/useReservationForm'
import StayPinStatus from '../../../../components/Planner/StayPinStatus'
import { buildAssignmentOptions } from '../../../../components/Planner/assignmentOptions'
import { useTripStore } from '../../../../store/tripStore'
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
@@ -60,7 +61,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
const {
form, setForm, set, isSaving, setIsSaving, pendingFiles, setPendingFiles, travelerIds, setTravelerIds, toggleTraveler,
isEndBeforeStart, endTimeBeforeStartTime, dateBounds, startDate, startTime, setStartDate, setStartTime, takeStopDay,
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, saveData: buildSaveData,
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, stayPin, saveData: buildSaveData,
} = useReservationForm(EMPTY, days, places)
const assignmentOptions = useMemo(
() => buildAssignmentOptions(days, assignments, t, locale),
@@ -85,6 +86,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
expense.reset()
setPendingFiles([])
setTravelerIds(travelerIdsOf(editingReservation))
stayPin.reset()
const res = editingReservation
if (res) {
@@ -93,6 +95,8 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
const pf = reservationPrefill
setForm({ ...EMPTY, ...reservationFieldsFromPrefill(pf, days, false) })
setPendingFiles(pf._sourceFiles ?? [])
// Find an imported stay on the map now, so the review says whether it will show there.
if (pf.type === 'hotel') stayPin.lookUp(pf._venue?.name || pf.title, pf._venue?.address)
} else {
// Opened from a day's toolbar: start on that day rather than on a blank
// date the user has to look up again (#1998). A hotel spans to the next
@@ -335,6 +339,13 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
placeholder={t('reservations.locationPlaceholder')}
className={FIELD_CLS}
/>
<div className="mt-2 empty:hidden">
<StayPinStatus
pin={stayPin.pin}
hidden={!!form.hotel_place_id}
onRetry={() => stayPin.lookUp(form.title, form.hotel_address)}
/>
</div>
</>
)}
@@ -18,7 +18,7 @@ import type { Translate } from './plannerTypes'
import { useBookingEdits } from './useBookingEdits'
type Options = Parameters<typeof useBookingEdits>[0]
type ImportedStay = { place_id?: number; venue?: { name?: string; address?: string | null }; address?: string }
type ImportedStay = { place_id?: number; venue?: { name?: string; address?: string | null; lat?: number; lng?: number }; address?: string }
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
const t = ((key: string) => key) as Translate
@@ -231,6 +231,32 @@ describe('useBookingEdits', () => {
expect(actions.addReservation).toHaveBeenCalledTimes(4)
})
it('FE-TP-BOOKING-007b: a stay lands where the review found it, falls back to its name, and says when it has no spot', async () => {
const { result } = renderEdits()
// The review already found it: no second search, the place sits right there.
vi.mocked(mapsApi.search).mockClear()
await saveHotel(result, { venue: { name: 'Gracery', address: '1-19-1 Kabukicho', lat: 35.69, lng: 139.7 } })
expect(mapsApi.search).not.toHaveBeenCalled()
expect(actions.addPlace).toHaveBeenLastCalledWith(42, { name: 'Gracery', lat: 35.69, lng: 139.7, address: '1-19-1 Kabukicho' })
expect(toast.warning).not.toHaveBeenCalled()
// A romanized address that matches nothing no longer sinks the hotel's name.
vi.mocked(mapsApi.search).mockImplementation(async (query: string) =>
(query === 'Gracery' ? { places: [{ lat: 35.69, lng: 139.7 }] } : { places: [] }) as never)
await saveHotel(result, { venue: { name: 'Gracery', address: '1-19-1 Kabukicho' } })
expect(vi.mocked(mapsApi.search).mock.calls.map(([q]) => q)).toEqual(['Gracery 1-19-1 Kabukicho', 'Gracery'])
expect(actions.addPlace).toHaveBeenLastCalledWith(42, { name: 'Gracery', lat: 35.69, lng: 139.7, address: '1-19-1 Kabukicho' })
expect(toast.warning).not.toHaveBeenCalled()
// Found nowhere: the booking is still saved, and the reader is told it has no pin.
vi.mocked(mapsApi.search).mockResolvedValue({ places: [] } as never)
await saveHotel(result, { venue: { name: 'Nowhere Inn', address: 'Nowhere 1' } })
expect(actions.addPlace).toHaveBeenLastCalledWith(42, { name: 'Nowhere Inn', lat: null, lng: null, address: 'Nowhere 1' })
expect(toast.warning).toHaveBeenCalledWith('reservations.import.savedWithoutPin')
expect(actions.addReservation).toHaveBeenCalledTimes(3)
})
it('FE-TP-BOOKING-008: a booking that cannot be saved says so, whatever was thrown', async () => {
const { result } = renderEdits()
+25 -18
View File
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import type { BookingImportPreviewItem } from '@trek/shared'
import { mapsApi } from '../../api/client'
import { receiptToPrefill } from '../../components/Budget/CostsPanel.helpers'
import type { ExpensePrefill } from '../../components/Budget/CostsPanel'
import { parsedItemToDraft, isTransportItem, isUnplaceableItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
import { getImportFiles, deleteImportFiles } from '../../db/offlineDb'
import { accommodationRepo } from '../../repo/accommodationRepo'
import { findOnMap, stayQueries } from '../../repo/mapMatchRepo'
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
import type { TripStoreState } from '../../store/tripStore'
import { isEffectivelyOffline } from '../../sync/networkMode'
@@ -63,7 +63,10 @@ export function useBookingEdits(options: BookingEditsOptions) {
// an existing place by name, else geocode the address and create one, then link it.
const acc = (data as Record<string, any>).create_accommodation
if (data.type === 'hotel' && acc && acc.venue && !acc.place_id) {
acc.place_id = (await resolveImportedPlace(acc.venue)) ?? undefined
const resolved = await resolveImportedPlace(acc.venue)
acc.place_id = resolved?.id ?? undefined
// Saved, but nowhere on the map: say so rather than leave the pin to go missing.
if (resolved && !resolved.placed) toast.warning(t('reservations.import.savedWithoutPin'))
delete acc.venue
}
// A hotel's address lives on the linked place. Write an edited address
@@ -141,9 +144,13 @@ export function useBookingEdits(options: BookingEditsOptions) {
}
// ── Review-before-save booking import ───────────────────────────────────────
// Match an existing trip place by name, else geocode the reviewed address and
// create one. Returns the place id (or null if even creation failed).
const resolveImportedPlace = async (venue: { name?: string; address?: string | null }): Promise<number | null> => {
// Match an existing trip place by name, else create one at the spot the review
// found, or the first spot the map search knows for its name and address.
// Returns the place and whether it has a spot on the map (null if even creation
// failed).
const resolveImportedPlace = async (
venue: { name?: string; address?: string | null; lat?: number; lng?: number },
): Promise<{ id: number; placed: boolean } | null> => {
const name = (venue.name || '').trim()
const n = name.toLowerCase()
if (n) {
@@ -153,23 +160,23 @@ export function useBookingEdits(options: BookingEditsOptions) {
// the booking waits in the queue behind that place and is sent with its real
// id. Online the booking goes out at once, and day_accommodations.place_id
// carries a foreign key, so a temp id there is a 500 instead of a saved booking.
if (existing && (existing.id > 0 || isEffectivelyOffline())) return existing.id
if (existing && (existing.id > 0 || isEffectivelyOffline())) return { id: existing.id, placed: existing.lat != null && existing.lng != null }
}
let lat: number | null = null
let lng: number | null = null
let lat: number | null = venue.lat ?? null
let lng: number | null = venue.lng ?? null
let address: string | null = venue.address ?? null
try {
const query = venue.address ? `${name} ${venue.address}`.trim() : name
// Offline there is no geocoder to ask; the place is created without coords.
if (query && !isEffectivelyOffline()) {
const res = await mapsApi.search(query)
const hit = res?.places?.[0] as { lat?: number; lng?: number; address?: string } | undefined
if (hit && hit.lat != null && hit.lng != null) {
if (lat == null || lng == null) {
try {
// Name and address, then each alone: a booking site's romanized address
// often matches nothing and sinks the hotel's name with it. Offline
// findOnMap answers null and the place is created without coords.
const hit = await findOnMap(stayQueries(name, venue.address))
if (hit) {
lat = hit.lat; lng = hit.lng
if (!address && hit.address) address = hit.address
}
}
} catch { /* geocode failure is non-fatal: create the place without coords */ }
} catch { /* geocode failure is non-fatal: create the place without coords, and say so */ }
}
try {
// Through the store, not placesApi directly: the API answers { place },
// and reading .id off that wrapper linked nothing. Every save of the
@@ -178,7 +185,7 @@ export function useBookingEdits(options: BookingEditsOptions) {
// find it. addPlace unwraps the response and puts the place into
// `places`, so the next save reuses it.
const place = await tripActions.addPlace(tripId, { name: name || address || 'Accommodation', lat, lng, address })
return place && (place.id > 0 || isEffectivelyOffline()) ? place.id : null
return place && (place.id > 0 || isEffectivelyOffline()) ? { id: place.id, placed: lat != null && lng != null } : null
} catch { return null }
}
+5
View File
@@ -104,6 +104,11 @@ export const aiConnectionsRepo = {
return aiAdminConnectionResponseSchema.parse(await aiAdminApi.saveConnection(body));
},
async setAdminModel(model: string) {
requireOnline();
return aiAdminConnectionResponseSchema.parse(await aiAdminApi.setModel(model));
},
async removeAdminConnection(): Promise<void> {
requireOnline();
await aiAdminApi.removeConnection();
+69
View File
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mapsApi } from '../api/client';
import { isEffectivelyOffline } from '../sync/networkMode';
import { findOnMap, stayQueries } from './mapMatchRepo';
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
vi.mock('../api/client', () => ({ mapsApi: { search: vi.fn() } }));
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
vi.mocked(mapsApi.search).mockResolvedValue({ places: [] } as never);
});
describe('finding a stay on the map', () => {
it('FE-MAP-MATCH-001: asks name and address together, then the name, then the address, each once', () => {
expect(stayQueries('Hotel Gracery', '1-19-1 Kabukicho, Shinjuku')).toEqual([
'Hotel Gracery 1-19-1 Kabukicho, Shinjuku',
'Hotel Gracery',
'1-19-1 Kabukicho, Shinjuku',
]);
expect(stayQueries(' Hut ', null)).toEqual(['Hut']);
expect(stayQueries('', 'Main 1')).toEqual(['Main 1']);
expect(stayQueries('Inn', 'Inn')).toEqual(['Inn Inn', 'Inn']);
expect(stayQueries('', ' ')).toEqual([]);
});
it('FE-MAP-MATCH-002: a romanized address that finds nothing falls back to the hotel name', async () => {
vi.mocked(mapsApi.search).mockImplementation(async (query: string) =>
query === 'Hotel Gracery'
? ({
places: [{ name: 'Hotel Gracery Shinjuku', address: 'Kabukicho, Tokyo', lat: 35.69, lng: 139.7 }],
} as never)
: ({ places: [] } as never)
);
await expect(findOnMap(stayQueries('Hotel Gracery', '1-19-1 Kabukicho'), 'en')).resolves.toEqual({
lat: 35.69,
lng: 139.7,
label: 'Hotel Gracery Shinjuku, Kabukicho, Tokyo',
address: 'Kabukicho, Tokyo',
});
expect(vi.mocked(mapsApi.search).mock.calls.map(([q]) => q)).toEqual([
'Hotel Gracery 1-19-1 Kabukicho',
'Hotel Gracery',
]);
expect(mapsApi.search).toHaveBeenLastCalledWith('Hotel Gracery', 'en');
});
it('FE-MAP-MATCH-003: a row without coordinates is no match, and without a language the search is asked as before', async () => {
vi.mocked(mapsApi.search).mockResolvedValueOnce({
places: [
{ name: 'Nowhere', lat: null, lng: 2 },
{ name: 'Inn', lat: 1, lng: 2 },
],
} as never);
await expect(findOnMap(['Inn'])).resolves.toEqual({ lat: 1, lng: 2, label: 'Inn' });
expect(mapsApi.search).toHaveBeenCalledWith('Inn');
vi.mocked(mapsApi.search).mockResolvedValueOnce(undefined as never);
await expect(findOnMap(['Gone'])).resolves.toBeNull();
});
it('FE-MAP-MATCH-004: offline there is nothing to ask', async () => {
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
await expect(findOnMap(['Hotel'])).resolves.toBeNull();
expect(mapsApi.search).not.toHaveBeenCalled();
});
});
+47
View File
@@ -0,0 +1,47 @@
import { mapsApi } from '../api/client';
import { isEffectivelyOffline } from '../sync/networkMode';
/** A spot the map search found for a place that so far is only a name and an address. */
export interface MapMatch {
lat: number;
lng: number;
/** What the search found, as the reader sees it: its name, and its address when it has one. */
label: string;
/** The found place's own address, for a stay whose booking named none. */
address?: string;
}
/**
* The ways to ask for a stay, best first: name and address together, then the
* name alone, then the address alone. A booking site's romanized address (an
* Agoda stay in Japan, say) often matches nothing, and asked together with the
* hotel's name it sinks the name too; the name alone usually finds the hotel.
*/
export function stayQueries(name: string, address: string | null | undefined): string[] {
const n = name.trim();
const a = (address ?? '').trim();
const queries = [n && a ? `${n} ${a}` : '', n, a].filter((q) => q.length >= 3);
return [...new Set(queries)];
}
/**
* The first spot the map search knows for one of `queries`, asked in order.
* Online only, on purpose: offline there is no search to ask, and the answer
* is null rather than a guess.
*/
export async function findOnMap(queries: string[], lang?: string): Promise<MapMatch | null> {
if (isEffectivelyOffline()) return null;
for (const query of queries) {
const res = lang ? await mapsApi.search(query, lang) : await mapsApi.search(query);
for (const row of res?.places ?? []) {
const place = row as { lat?: unknown; lng?: unknown; name?: unknown; address?: unknown };
const lat = typeof place.lat === 'number' ? place.lat : NaN;
const lng = typeof place.lng === 'number' ? place.lng : NaN;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
const parts = [place.name, place.address].filter((v): v is string => typeof v === 'string' && v.trim() !== '');
const address = typeof place.address === 'string' && place.address.trim() ? place.address : undefined;
return { lat, lng, label: parts.join(', ') || query, ...(address ? { address } : {}) };
}
}
return null;
}
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { offlineDb } from '../db/offlineDb';
import { routeCacheRepo } from './routeCacheRepo';
afterEach(async () => {
vi.restoreAllMocks();
vi.useRealTimers();
await offlineDb.routeCache.clear();
});
const ROUTE = { coordinates: [[48.1, 11.5]], distance: 4200, duration: 600, legs: [] };
describe('road routes kept on the device', () => {
it('FE-ROUTE-CACHE-001: a kept route comes back as it went in, a missing one as null', async () => {
await routeCacheRepo.put('driving:km:11.5,48.1;11.6,48.2', ROUTE);
await expect(routeCacheRepo.get('driving:km:11.5,48.1;11.6,48.2')).resolves.toEqual(ROUTE);
await expect(routeCacheRepo.get('walking:km:11.5,48.1;11.6,48.2')).resolves.toBeNull();
});
it('FE-ROUTE-CACHE-002: a route older than a month is asked again', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-09-01T00:00:00Z'));
await routeCacheRepo.put('driving:km:old', ROUTE);
vi.setSystemTime(new Date('2026-10-25T00:00:00Z'));
await expect(routeCacheRepo.get('driving:km:old')).resolves.toBeNull();
});
it('FE-ROUTE-CACHE-003: a database that fails is a miss on read and nothing on write', async () => {
vi.spyOn(offlineDb.routeCache, 'get').mockRejectedValueOnce(new Error('closed'));
await expect(routeCacheRepo.get('driving:km:any')).resolves.toBeNull();
vi.spyOn(offlineDb.routeCache, 'put').mockRejectedValueOnce(new Error('quota'));
await expect(routeCacheRepo.put('driving:km:any', ROUTE)).resolves.toBeUndefined();
});
});
+48
View File
@@ -0,0 +1,48 @@
import { offlineDb } from '../db/offlineDb';
/** Roads change; an answer older than this is asked again. */
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
/** How many routes the device keeps before the oldest give way. */
const MAX_ROWS = 3000;
/** Pruning counts the table, so it runs every so many writes rather than on each one. */
const PRUNE_EVERY = 100;
let writes = 0;
async function prune(): Promise<void> {
const extra = (await offlineDb.routeCache.count()) - MAX_ROWS;
if (extra <= 0) return;
const oldest = await offlineDb.routeCache.orderBy('savedAt').limit(extra).primaryKeys();
await offlineDb.routeCache.bulkDelete(oldest);
}
/**
* Road routes kept on the device, by RouteCalculator's cache key, so a trip that
* shows its whole route opens with its roads instead of asking the router for
* every leg again, one a second, on every visit (#2794). A convenience only:
* anything this store cannot read or write is simply routed again.
*/
export const routeCacheRepo = {
/** The route kept under `key`, unless it is missing or too old. */
async get<T>(key: string): Promise<T | null> {
try {
const row = await offlineDb.routeCache.get(key);
if (!row || Date.now() - row.savedAt > MAX_AGE_MS) return null;
return row.route as T;
} catch {
// A closed or blocked database: the leg is routed again.
return null;
}
},
/** Keeps a route the router just answered. */
async put(key: string, route: unknown): Promise<void> {
try {
await offlineDb.routeCache.put({ key, route, savedAt: Date.now() });
writes += 1;
if (writes % PRUNE_EVERY === 0) await prune();
} catch {
// A full or blocked database: the route still serves this visit from memory.
}
},
};
@@ -56,6 +56,7 @@ const HISTORY: Record<number, StoreSpec> = {
11: { ferrataTiles: '[tripId+tile], tripId, fetchedAt' },
12: { tourStationVisits: '[assignment_id+station_key], trip_id, assignment_id' },
13: { tourDrafts: 'tripId' },
14: { routeCache: 'key, savedAt' },
};
const HISTORY_VERSIONS = Object.keys(HISTORY)
@@ -136,6 +137,7 @@ function rowsAt(version: number): Record<string, Record<string, unknown>> {
ferrataTiles: { tripId: 1, tile: '930:236', fetchedAt: 11, ferratas: [] },
tourStationVisits: { assignment_id: 71, station_key: 'stop-01', status: 'visited', trip_id: 1 },
tourDrafts: { tripId: 1, draft: { payload: '{"version":11}', updated_at: '2026-10-22 09:00:00' } },
routeCache: { key: 'driving:km:11.5,48.1;11.6,48.2', route: { coordinates: [], legs: [] }, savedAt: 13 },
};
const tables = schemaAt(version);
return Object.fromEntries(Object.entries(all).filter(([table]) => table in tables));
@@ -344,6 +346,24 @@ const UPGRADE_CASES: Record<number, UpgradeCase> = {
expect((await db.tourDrafts.get(3))?.draft.payload).toBe('{"name":"C"}');
},
},
14: {
intent: 'adds the road routes kept on the device and keeps the account drafts of the Tours planner',
check: async (db) => {
expect((await db.tourDrafts.get(1))?.draft.payload).toBe('{"version":11}');
await db.table('routeCache').bulkPut([
{ key: 'driving:km:1,2;3,4', route: { legs: [] }, savedAt: 20 },
{ key: 'walking:km:1,2;3,4', route: { legs: [] }, savedAt: 10 },
]);
// One row per key: the same route answered again replaces the old one.
await db.table('routeCache').put({ key: 'driving:km:1,2;3,4', route: { legs: [1] }, savedAt: 30 });
expect(await db.routeCache.count()).toBe(2);
// The oldest come first, which is how the store prunes.
expect(await db.routeCache.orderBy('savedAt').primaryKeys()).toEqual([
'walking:km:1,2;3,4',
'driving:km:1,2;3,4',
]);
},
},
};
describe('offlineDb upgrades', () => {
@@ -7,7 +7,7 @@ import MAssistantHomeScreen from '../../../src/mobile/screens/assistant/MAssista
import { chatOf, useAssistantStore } from '../../../src/store/assistantStore';
import { act, cleanup, fireEvent, render, screen } from '../../helpers/render';
// FE-MOB-ASSIST-HOME-001 to FE-MOB-ASSIST-HOME-005
// FE-MOB-ASSIST-HOME-001 to FE-MOB-ASSIST-HOME-006
//
// The phone's home chat, full screen outside a trip, around the shared chat
// surface, which is replaced here: what is tested is when it shows, its
@@ -141,4 +141,9 @@ describe('MAssistantHomeScreen', () => {
renderAt('/settings');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('FE-MOB-ASSIST-HOME-006: brings its own m-root, so the phone tokens resolve outside the mobile shell', () => {
renderAt('/dashboard');
expect(screen.getByRole('dialog', { name: 'Assistant' }).closest('.m-root')).not.toBeNull();
});
});
@@ -359,12 +359,22 @@ describe('MAdminPluginsPanel — Discover', () => {
await waitFor(() => expect(toastMessages()).toContain('Installed'));
});
it('FE-MOB-PLUGP-018: an entry no published version fits gets a dead, explained button', async () => {
it('FE-MOB-PLUGP-018: an entry no published version fits installs only after the admin accepts the risk', async () => {
let body: unknown = null;
await openDiscover([registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })]);
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null });
}));
const btn = await screen.findByRole('button', { name: 'Incompatible' });
expect(btn).toBeDisabled();
const btn = await screen.findByRole('button', { name: /^Install$/ });
expect(btn).toHaveAttribute('title', 'Needs TREK >=4.0.0 — this server runs 3.3.0');
fireEvent.click(btn);
const sheet = await screen.findByRole('dialog', { name: 'Untested on this TREK' });
expect(body).toBeNull();
fireEvent.click(within(sheet).getByRole('button', { name: 'Install anyway' }));
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', acceptTrekRange: true }));
});
it('FE-MOB-PLUGP-019: when only an older release fits, that version is offered and installed', async () => {
@@ -1806,7 +1816,7 @@ describe('MAdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RAN
expect(await screen.findByText('Version checks off')).toBeInTheDocument();
});
it('FE-MOB-PLUGP-BYPASS-002: offers "Install anyway" and asks before installing', async () => {
it('FE-MOB-PLUGP-BYPASS-002: offers Install and asks before installing', async () => {
let body: unknown = null;
mockPanel([], [incompatibleEntry()], { ignoreTrekRange: true });
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
@@ -1816,7 +1826,7 @@ describe('MAdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RAN
render(<MAdminPluginsPanel />);
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
const btn = await screen.findByRole('button', { name: 'Install anyway' });
const btn = await screen.findByRole('button', { name: /^Install$/ });
expect(btn).toBeEnabled();
fireEvent.click(btn);
@@ -1834,7 +1844,7 @@ describe('MAdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RAN
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }); }));
render(<MAdminPluginsPanel />);
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
fireEvent.click(await screen.findByRole('button', { name: 'Install anyway' }));
fireEvent.click(await screen.findByRole('button', { name: /^Install$/ }));
const sheet = await screen.findByRole('dialog', { name: 'Outside its supported TREK versions' });
fireEvent.click(within(sheet).getByRole('button', { name: 'Cancel' }));
+4
View File
@@ -11,6 +11,10 @@ services:
- CHOWN
- SETUID
- SETGID
# dumb-init runs as root and forwards SIGTERM to node, which runs as the
# node user; signalling another user's process needs KILL. Without it
# docker stop ends in SIGKILL and the graceful shutdown never runs.
- KILL
tmpfs:
- /tmp:noexec,nosuid,size=128m
ports:
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
// Build server/assets/airports.json from OurAirports (davidmegginson.github.io/ourairports-data).
// License: Public Domain. Keeps large/medium airports with an IATA code; timezone derived from coords via tz-lookup.
// License: Public Domain. Keeps large/medium airports with an IATA code, small ones only with scheduled service;
// timezone derived from coords via tz-lookup.
import fs from 'node:fs';
import https from 'node:https';
import path from 'node:path';
@@ -91,7 +92,9 @@ for (let i = 1; i < rows.length; i++) {
if (!KEEP.has(r[TYPE])) continue;
const iata = r[IATA]?.trim().toUpperCase();
if (!iata || iata.length !== 3) continue;
if (r[SERVICE] !== 'yes') continue;
// The scheduled_service flag lags reality: Little Cayman (LYB) has daily flights and is
// marked "no". So it only filters the small fields, where it keeps every airstrip with a code out.
if (r[TYPE] === 'small_airport' && r[SERVICE] !== 'yes') continue;
const lat = Number(r[LAT]);
const lng = Number(r[LNG]);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
+1 -1
View File
@@ -5,7 +5,7 @@
"@typescript-eslint/no-require-imports": 18,
"@typescript-eslint/no-unsafe-function-type": 1,
"@typescript-eslint/no-unused-vars": 36,
"no-await-in-loop": 563,
"no-await-in-loop": 562,
"no-console": 235,
"no-empty": 6
},
+2 -2
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,
@@ -13,7 +13,7 @@
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
"src/nest/journey/journey-domain.service.ts": 1659,
"src/nest/places/places.service.ts": 1592,
"src/nest/plugins/plugin-runtime.service.ts": 1463,
"src/nest/plugins/plugin-runtime.service.ts": 1460,
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
"src/nest/reservations/reservations.service.ts": 1237,
"src/nest/trips/trips.service.ts": 1006,
+4
View File
@@ -35,6 +35,8 @@ export class Plugins {
update_block_version?: string | null;
trek_range?: string | null;
update_hold: number & Opt = 0;
trek_range_accepted_version?: string | null;
trek_range_accepted_host?: string | null;
}
export const PluginsSchema = defineEntity({
@@ -72,5 +74,7 @@ export const PluginsSchema = defineEntity({
update_block_version: p.text().nullable(),
trek_range: p.text().nullable(),
update_hold: p.integer().default(0),
trek_range_accepted_version: p.text().nullable(),
trek_range_accepted_host: p.text().nullable(),
},
});
+1 -1
View File
@@ -370,7 +370,7 @@ const aiProviderStateSchema = z.discriminatedUnion('adapter', [
adapter: z.literal('openai-responses'),
items: z.array(z.record(z.string(), z.unknown())),
}),
// Anthropic's content blocks of an answer that searched the web, replayed with their search results.
// Anthropic's content blocks of an answer that searched the web or thought, replayed as they came.
z.object({
adapter: z.literal('anthropic-messages'),
blocks: z.array(z.record(z.string(), z.unknown())),
+2
View File
@@ -34,4 +34,6 @@ export interface PluginsTable {
update_block_version: InsertOptional<string | null>;
trek_range: InsertOptional<string | null>;
update_hold: InsertOptional<number>;
trek_range_accepted_version: InsertOptional<string | null>;
trek_range_accepted_host: InsertOptional<string | null>;
}
@@ -0,0 +1,18 @@
import { Migration } from '@mikro-orm/migrations';
/**
* An admin can run one plugin outside the TREK versions its author declared,
* instead of switching the check off for every plugin through
* TREK_PLUGINS_IGNORE_TREK_RANGE. The consent belongs to what the admin was
* shown: `trek_range_accepted_version` is the plugin version they accepted and
* `trek_range_accepted_host` the TREK version it ran on then. An update of the
* plugin or a new TREK major asks again.
*/
export class Migration20200101046310_an_admin_can_run_a_plugin_outside_its_trek_range extends Migration {
override name = 'Migration20200101046310_an_admin_can_run_a_plugin_outside_its_trek_range';
override up(): void {
this.addSql('ALTER TABLE plugins ADD COLUMN trek_range_accepted_version TEXT');
this.addSql('ALTER TABLE plugins ADD COLUMN trek_range_accepted_host TEXT');
}
}
@@ -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);
}
/**
@@ -26,13 +26,16 @@ export interface PluginIdPermissionRow {
permissions: string | null;
}
/** PR17 — `SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?` (`assertActivatable`'s one-row activation gate). */
/** PR17 — the one-row activation gate `assertActivatable` reads: grants, dependencies, the TREK range and an admin's consent to run outside it. */
export interface PluginActivationGateRow {
permissions: string;
granted_permissions: string;
dependencies: string | null;
trek_range: string | null;
api_version: number | null;
version: string | null;
trek_range_accepted_version: string | null;
trek_range_accepted_host: string | null;
}
/** PR18 — `SELECT permissions, config FROM plugins WHERE id = ?` (`spawnActivated`). */
@@ -89,6 +92,8 @@ export interface PluginAdminRow {
dependencies: string | null;
operator_egress: number;
trek_range: string | null;
trek_range_accepted_version: string | null;
trek_range_accepted_host: string | null;
author_pubkey: string | null;
update_block_code: string | null;
update_block_detail: string | null;
@@ -144,7 +149,18 @@ export class PluginsRepository extends TrekRepository<Plugins> {
async findActivationGate(id: string): Promise<PluginActivationGateRow | null> {
const row = await this.findOne(
{ id },
{ fields: ['permissions', 'granted_permissions', 'dependencies', 'trek_range', 'api_version'] },
{
fields: [
'permissions',
'granted_permissions',
'dependencies',
'trek_range',
'api_version',
'version',
'trek_range_accepted_version',
'trek_range_accepted_host',
],
},
);
return row
? {
@@ -153,6 +169,9 @@ export class PluginsRepository extends TrekRepository<Plugins> {
dependencies: row.dependencies ?? null,
trek_range: row.trek_range ?? null,
api_version: row.api_version ?? null,
version: row.version ?? null,
trek_range_accepted_version: row.trek_range_accepted_version ?? null,
trek_range_accepted_host: row.trek_range_accepted_host ?? null,
}
: null;
}
@@ -370,6 +389,8 @@ export class PluginsRepository extends TrekRepository<Plugins> {
'dependencies',
'operator_egress',
'trek_range',
'trek_range_accepted_version',
'trek_range_accepted_host',
'author_pubkey',
'update_block_code',
'update_block_detail',
@@ -396,6 +417,8 @@ export class PluginsRepository extends TrekRepository<Plugins> {
dependencies: r.dependencies ?? null,
operator_egress: r.operator_egress,
trek_range: r.trek_range ?? null,
trek_range_accepted_version: r.trek_range_accepted_version ?? null,
trek_range_accepted_host: r.trek_range_accepted_host ?? null,
author_pubkey: r.author_pubkey ?? null,
update_block_code: r.update_block_code ?? null,
update_block_detail: r.update_block_detail ?? null,
@@ -545,6 +568,11 @@ export class PluginsRepository extends TrekRepository<Plugins> {
await this.nativeUpdate({ id }, { update_hold: hold ? 1 : 0 });
}
/** An admin accepted running this plugin version outside its TREK range, on this TREK. */
async acceptTrekRange(id: string, version: string, host: string): Promise<void> {
await this.nativeUpdate({ id }, { trek_range_accepted_version: version, trek_range_accepted_host: host });
}
/**
* RG8 (`commitUpload`, sideload step 2) — `` UPDATE plugins SET source_repo = ?,
* source_commit = NULL, sha256 = NULL, reviewed_at = NULL, author_pubkey = NULL,
@@ -7,11 +7,11 @@ import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ManagedForbidden } from '../common/managed';
import { ResponseContract } from '../common/response-contract';
import { AiAdminSettingsDto, AiInstanceConnectionDto } from './ai-connections.dto';
import { AiAdminSettingsDto, AiInstanceConnectionDto, AiModelUpdateDto } from './ai-connections.dto';
import { AiConnectionsService } from './ai-connections.service';
import { ChatgptAuthService } from './chatgpt-auth.service';
import { OpenrouterAuthService } from './openrouter-auth.service';
import { Body, Controller, Delete, Get, HttpCode, Post, Put, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, Patch, Post, Put, UseGuards } from '@nestjs/common';
import {
aiAdminConnectionResponseSchema,
aiAdminSettingsSchema,
@@ -63,6 +63,12 @@ export class AiAdminController {
return this.connections.saveInstance(user.id, body as AiInstanceConnectionRequest);
}
@Patch('connection/model')
@ResponseContract(aiAdminConnectionResponseSchema)
setModel(@CurrentUser() user: User, @Body() body: AiModelUpdateDto) {
return this.connections.setInstanceModel(user.id, body.model);
}
@Delete('connection')
@HttpCode(200)
@ResponseContract(successResponseSchema)
@@ -23,10 +23,21 @@ const cloudFetch: AiFetch = (url, init) => safeFetch(url, { ...init, signal: ini
*/
const adminServerFetch: AiFetch = (url, init) => safeFetchLlm(url, { ...init, signal: init.signal }, 5, null);
/**
* Whether an OpenAI model behind an API key goes through the Responses API:
* the reasoning models (the o-series, GPT-5 and later) refuse function tools
* on Chat Completions with a 400 (#2792). Their `-chat` variants do not reason
* and stay on Chat Completions, like the older GPT-4 line.
*/
export function openAiWantsResponses(model: string): boolean {
return /^(o\d|gpt-([5-9]|\d{2,}))/.test(model) && !/-chat/.test(model);
}
/**
* The chat client for a resolved connection: Anthropic's Messages API, the
* Responses API for a ChatGPT plan, and Chat Completions for everything
* OpenAI-compatible (OpenAI, Gemini, OpenRouter, an admin's own server).
* Responses API for a ChatGPT plan and OpenAI's reasoning models, and Chat
* Completions for everything else OpenAI-compatible (older OpenAI models,
* Gemini, OpenRouter, an admin's own server).
*/
@Injectable()
export class AiChatClientService {
@@ -45,6 +56,10 @@ export class AiChatClientService {
cloudFetch,
);
}
if (kind === 'openai' && openAiWantsResponses(model)) {
const apiKey = credential.apiKey ?? '';
return new OpenAiResponsesClient(model, () => Promise.resolve(apiKey), cloudFetch, 'openai');
}
switch (kind) {
case 'anthropic':
return new AnthropicMessagesClient(model, credential.apiKey ?? '', cloudFetch);
@@ -91,7 +91,17 @@ const PROVIDER_MESSAGES: Partial<Record<AssistantErrorCode, string>> = {
provider_unavailable: 'The provider is not responding',
};
/** A provider's failure as the refusal a REST route answers with. */
/**
* A provider's failure as the refusal a REST route answers with. An own server
* that answered without a model list says so in `reason`, so the form can name
* the address instead of calling a server that is up unresponsive (#2808).
*/
export function providerRefusal(err: AiProviderError): DomainError {
if (err.providerCode === 'no_model_list') {
return new DomainError(REFUSAL_STATUS[err.code], 'The server answered, but sent no model list', {
code: err.code,
details: { code: err.code, reason: 'no_model_list' },
});
}
return aiRefusal(err.code, PROVIDER_MESSAGES[err.code] ?? 'The provider refused the request');
}
@@ -328,6 +328,26 @@ export class AiConnectionsService implements SharedAiConnectionPort {
return this.modelsOf(adminId, row);
}
/**
* Picks the model of the instance's connection, one the provider offers. The
* only way to do so for a shared ChatGPT plan: saving the connection again
* needs a key, and a plan has a sign-in instead.
* @txIndependent reading the credential may renew a ChatGPT sign-in first; that write stands on its own.
*/
async setInstanceModel(adminId: number, model: string): Promise<AiAdminConnectionResponse> {
const row = await this.connections.findInstance();
if (!row) throw aiRefusal('not_found', 'No connection is set up for everyone');
const models = await this.modelsOf(adminId, row);
if (!models.some((m) => m.id === model)) throw aiRefusal('unknown_model', 'This model is not offered here');
await this.connections.setModel(row.id, model);
await this.audit.writeAudit({
userId: adminId,
action: 'ai.connection.saved',
details: { scope: 'instance', kind: row.kind, model },
});
return this.instanceConnection();
}
async removeInstance(adminId: number): Promise<void> {
const row = await this.connections.findInstance();
if (!row) throw aiRefusal('not_found', 'No connection is set up for everyone');
@@ -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;
}
}
@@ -86,9 +86,11 @@ export function toAnthropicMessages(messages: AiChatMessage[]): AnthropicMessage
* A block of an answer as it goes back to the provider: as it came, but a text
* block without the citations it carried, which the provider checks against
* the search results and which the history needs no more than their words.
* Always a copy: the request marks its blocks for the cache in place, and a
* mark left on the stored answer would go out again on every later step.
*/
function replayed(block: ContentBlock): ContentBlock {
return block.type === 'text' ? { type: 'text', text: asText(block.text) } : block;
return block.type === 'text' ? { type: 'text', text: asText(block.text) } : { ...block };
}
/** Anthropic's own web search, a server tool; a few searches a step keep the turn short. */
@@ -114,10 +116,17 @@ export function markHistoryForCache(messages: AnthropicMessage[], breakpoints: n
last.block.cache_control = CACHED;
let question = blocks.length - 1;
while (question >= 0 && !(blocks[question]?.role === 'user' && blocks[question]?.block.type === 'text')) question--;
const before = question > 0 ? blocks[question - 1] : undefined;
// A replayed thinking block goes back exactly as it came, so the mark sits on the block before it.
let mark = question - 1;
while (mark >= 0 && isThinking(blocks[mark]?.block)) mark--;
const before = mark >= 0 ? blocks[mark] : undefined;
if (breakpoints >= 2 && before && before !== last) before.block.cache_control = CACHED;
}
function isThinking(block: ContentBlock | undefined): boolean {
return block?.type === 'thinking' || block?.type === 'redacted_thinking';
}
const STOP_REASONS: Record<string, AiFinishReason> = { tool_use: 'tool-calls', max_tokens: 'length' };
/**
@@ -207,9 +216,10 @@ interface OpenBlock {
/**
* What one streamed Messages API turn has said so far, fed one event at a
* time. It keeps every content block as it came, so an answer that searched
* the web can be replayed with its search and its results (state()); an
* answer that did not is rebuilt from its text and calls as before.
* time. It keeps every content block as it came, thinking blocks with their
* signature included, so an answer that searched the web or thought can be
* replayed as it was (state()); any other answer is rebuilt from its text and
* calls as before.
*/
class AnthropicTurn {
inputTokens = 0;
@@ -246,6 +256,10 @@ class AnthropicTurn {
if (delta.type === 'text_delta' && asText(delta.text)) {
if (open) open.block.text = asText(open.block.text) + asText(delta.text);
yield { type: 'text-delta', delta: asText(delta.text) };
} else if (delta.type === 'thinking_delta' && open) {
open.block.thinking = asText(open.block.thinking) + asText(delta.thinking);
} else if (delta.type === 'signature_delta' && open) {
open.block.signature = asText(open.block.signature) + asText(delta.signature);
} else if (delta.type === 'input_json_delta' && open) {
open.json += asText(delta.partial_json);
} else if (delta.type === 'citations_delta') {
@@ -285,10 +299,17 @@ class AnthropicTurn {
}
}
/** The blocks to replay, for an answer that searched the web; null for one rebuilt from its text and calls. */
/**
* The blocks to replay, for an answer that searched the web or thought; null
* for one rebuilt from its text and calls. A model that thinks (Sonnet 5.5,
* Opus 5.5) wants its thinking blocks back unchanged with the rest of the
* turn, and writes what it says between tool calls into them: rebuilt from
* text and calls alone, the next step after a tool came back empty (#2789).
*/
state(): AiProviderState | null {
if (!this.searched) return null;
const blocks = [...this.blocks.entries()].sort(([a], [b]) => a - b).map(([, open]) => open.block);
const thought = blocks.some(isThinking);
if (!this.searched && !thought) return null;
return { adapter: 'anthropic-messages', blocks };
}
}
@@ -130,20 +130,22 @@ function citedSources(item: Record<string, unknown>): AiSource[] {
}
/**
* OpenAI's Responses API on a ChatGPT plan (Sign in with ChatGPT). Within the
* preview's limits: nothing stored, streamed, no output cap or temperature,
* the instructions as `instructions`, the tools in one namespace that carries
* a description. A 401 before the stream starts is answered once with a
* refreshed token; a second one means the sign-in has to be renewed.
* OpenAI's Responses API, on a ChatGPT plan (Sign in with ChatGPT) or, for the
* reasoning models, on an OpenAI API key, which take their function tools only
* here (#2792). Within the preview's limits: nothing stored, streamed, no
* output cap or temperature, the instructions as `instructions`, the tools in
* one namespace that carries a description. On a plan, a 401 before the stream
* starts is answered once with a refreshed token; a second one means the
* sign-in has to be renewed. A key has nothing to refresh.
*/
export class OpenAiResponsesClient implements AiChatClient {
readonly kind = 'chatgpt' as const;
readonly webSearch = true;
constructor(
readonly model: string,
private readonly token: ChatgptTokenSource,
private readonly fetcher: AiFetch,
readonly kind: 'chatgpt' | 'openai' = 'chatgpt',
) {}
async *stream(request: AiChatRequest, signal: AbortSignal): AsyncGenerator<AiChatEvent> {
@@ -227,7 +229,7 @@ export class OpenAiResponsesClient implements AiChatClient {
try {
return await send(accessToken);
} catch (err) {
if (!(err instanceof AiProviderError) || err.status !== 401) throw err;
if (this.kind !== 'chatgpt' || !(err instanceof AiProviderError) || err.status !== 401) throw err;
return send(await this.token(accessToken));
}
}
@@ -1,3 +1,4 @@
import { intlLocale } from './assistant-words';
import { fileKeyOf } from './cards/card-values';
import type { AssistantFocus, AssistantMention } from '@trek/shared';
@@ -412,7 +413,7 @@ export function todayIn(timeZone: string, now: Date): string {
/** The English name of a locale's language, for the instruction to answer in it. */
export function languageName(locale: string): string {
try {
return new Intl.DisplayNames(['en'], { type: 'language' }).of(locale) ?? locale;
return new Intl.DisplayNames(['en'], { type: 'language' }).of(intlLocale(locale)) ?? locale;
} catch {
return locale;
}
@@ -17,6 +17,15 @@ export function supportedLocale(locale: string): string {
return SUPPORTED_LANGUAGE_CODES.includes(language) ? language : 'en';
}
/**
* The BCP 47 tag of a request's locale, for Intl: a TREK code reads as its
* language ('br' is Brazilian Portuguese here, Breton to Intl), anything else
* is passed through.
*/
export function intlLocale(locale: string): string {
return SUPPORTED_LANGUAGE_CODES.includes(locale) ? getIntlLanguage(locale) : locale;
}
/** The words of one locale's table, or none when it cannot be read. */
export async function localeTable(code: string): Promise<Table> {
try {
@@ -1,3 +1,4 @@
import { intlLocale } from '../assistant-words';
import type { PreviewContext } from './card-types';
import type { AssistantChangeLabelKey, AssistantChangeRow } from '@trek/shared';
@@ -57,7 +58,7 @@ export class Rows {
/** A locale Intl accepts, the user's when it can. */
export function localeOf(ctx: Pick<PreviewContext, 'locale'>): string {
try {
return Intl.getCanonicalLocales(ctx.locale)[0] ?? 'en';
return Intl.getCanonicalLocales(intlLocale(ctx.locale))[0] ?? 'en';
} catch {
return 'en';
}
@@ -71,6 +71,7 @@ export const MANAGED_ROUTE_ALLOW_LIST: string[] = [
'AiAdminController.removeConnection',
'AiAdminController.saveConnection',
'AiAdminController.saveSettings',
'AiAdminController.setModel',
'AiAdminController.settings',
'AiAdminController.signOutChatgpt',
'AiAdminController.startChatgpt',
+73 -29
View File
@@ -26,6 +26,12 @@ const tagsResponseSchema = z.object({
/** The OpenAI-compatible list (`GET /v1/models`) that LM Studio, vLLM and llama.cpp serve. */
const openAiModelsSchema = z.object({ data: z.array(z.object({ id: z.string().optional() })) });
/** One place a server may list its models, and how its answer reads. */
interface ModelListSource {
url: string;
read: (body: unknown) => { name?: string; size?: number }[] | undefined;
}
/** The Bearer header the extraction sends, when a key is configured. */
function authHeader(apiKey: string | undefined): Record<string, string> {
return apiKey ? { authorization: `Bearer ${apiKey}` } : {};
@@ -43,11 +49,23 @@ function upstreamRefusal(status: number, apiKey: string | undefined, fallback: s
apiKey
? `Local LLM server rejected the API key (${status})`
: `Local LLM server requires an API key (${status}). Save the key for this address first.`,
{ code: 'llm_key_refused' },
);
}
return new DomainError(502, fallback);
}
/**
* A URL without its trailing slashes. Scanned off instead of `/\/+$/`-replaced:
* that pattern re-walks the slash run from every start position of the
* admin-supplied URL.
*/
function trimTrailingSlashes(url: string): string {
let end = url.length;
while (end > 0 && url.charCodeAt(end - 1) === 47 /* '/' */) end--;
return url.slice(0, end);
}
/**
* Admin helpers for managing a local OpenAI-compatible LLM server (Ollama).
* Talks to Ollama's *management* API (`/api/tags`, `/api/pull`), which lives at
@@ -72,11 +90,7 @@ export class LlmLocalService {
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new HttpException({ error: 'Base URL must be http(s)' }, 400);
}
// Trailing slashes are scanned off instead of `/\/+$/`-replaced: that pattern
// re-walks the slash run from every start position of the admin-supplied URL.
let end = raw.length;
while (end > 0 && raw.charCodeAt(end - 1) === 47 /* '/' */) end--;
return raw.slice(0, end).replace(/\/v1$/, '');
return trimTrailingSlashes(raw).replace(/\/v1$/, '');
}
/**
@@ -94,39 +108,69 @@ export class LlmLocalService {
}
/**
* List models already pulled on the local server. A server without Ollama's
* `/api/tags` (LM Studio, vLLM, llama.cpp) answers 404 there, so its
* OpenAI-compatible `/v1/models` is asked instead.
* List the models the server offers. Ollama's `/api/tags` is asked first, then
* the OpenAI-compatible list: at the address as entered (the one the chat
* calls `/chat/completions` on, `https://api.groq.com/openai/v1` say), then at
* `/v1/models` under the root for an address given without `/v1`.
*
* An answer only counts when it reads as a model list. LM Studio answers an
* unknown path with 200 and an error object, and a proxy may answer it with a
* login page, so stopping at the first answer that is not a 404 refused
* servers whose list was one request further (#2808).
*/
async listModels(
baseUrl: string | undefined,
apiKey?: string,
): Promise<{ models: { name: string; size: number }[] }> {
const root = this.ollamaRoot(baseUrl);
let res = await this.getModelList(`${root}/api/tags`, root, apiKey);
let openAi = false;
if (res.status === 404) {
const fallback = await this.getModelList(`${root}/v1/models`, root, apiKey);
if (fallback.ok) {
discardBody(res);
res = fallback;
openAi = true;
const asEntered = trimTrailingSlashes((baseUrl ?? '').trim()) || root;
const lists: ModelListSource[] = [
{ url: `${root}/api/tags`, read: (body) => tagsResponseSchema.safeParse(body).data?.models },
...[...new Set([`${asEntered}/models`, `${root}/v1/models`])].map((url) => ({
url,
read: (body: unknown) => openAiModelsSchema.safeParse(body).data?.data.map((m) => ({ name: m.id, size: 0 })),
})),
];
const seen = { firstStatus: 0, refusedStatus: 0 };
const listed = await this.firstModelList(lists, 0, root, apiKey, seen);
if (listed) return { models: listed.map((m) => ({ name: m.name ?? '', size: m.size ?? 0 })).filter((m) => m.name) };
if (seen.refusedStatus) throw upstreamRefusal(seen.refusedStatus, apiKey, '');
throw new DomainError(502, `Local LLM server sent no model list (${seen.firstStatus})`, {
code: 'llm_no_model_list',
});
}
/**
* The first of `lists`, from `index` on, that answers with a readable list,
* asked one after the other. Only the first request tells whether the server
* is there at all; a later one failing is that path, not the server.
*/
private async firstModelList(
lists: ModelListSource[],
index: number,
root: string,
apiKey: string | undefined,
seen: { firstStatus: number; refusedStatus: number },
): Promise<{ name?: string; size?: number }[] | null> {
const list = lists[index];
if (!list) return null;
let res: Response | null = null;
try {
res = await this.getModelList(list.url, root, apiKey);
} catch (err) {
if (index === 0) throw err;
}
if (res) {
seen.firstStatus ||= res.status;
if (res.status === 401 || res.status === 403) seen.refusedStatus ||= res.status;
if (res.ok) {
const listed = list.read(await readCappedJson<unknown>(res, MAX_LIST_BYTES).catch(() => undefined));
if (listed) return listed;
} else {
// Neither list exists: report the first answer, as before.
discardBody(fallback);
discardBody(res);
}
}
if (!res.ok) {
discardBody(res);
throw upstreamRefusal(res.status, apiKey, `Local LLM server error (${res.status})`);
}
const body = await readCappedJson<unknown>(res, MAX_LIST_BYTES);
const listed = openAi
? openAiModelsSchema.safeParse(body).data?.data.map((m) => ({ name: m.id, size: 0 }))
: tagsResponseSchema.safeParse(body).data?.models;
if (!listed) throw new DomainError(502, 'Local LLM server sent an unreadable model list');
const models = listed.map((m) => ({ name: m.name ?? '', size: m.size ?? 0 })).filter((m) => m.name);
return { models };
return this.firstModelList(lists, index + 1, root, apiKey, seen);
}
private async getModelList(url: string, root: string, apiKey: string | undefined): Promise<Response> {
@@ -1,5 +1,7 @@
/**
* Minimal Ollama native-API client used by the extraction router.
* Minimal Ollama native-API client used by the extraction router. A server that
* has no `/api/chat` gets the same extraction over OpenAI-compatible
* `/chat/completions` instead (see extractOpenAiCompatible below).
*
* Why not the OpenAI-compatible `/v1/chat/completions` path the rest of llm-parse uses?
* Ollama's `/v1` endpoint does NOT faithfully honour OpenAI's `response_format:{json_schema,strict}`
@@ -12,7 +14,9 @@
*/
import { readEnv } from '../../../app-config';
import { safeFetchLlm } from '../../../utils/ssrfGuard';
import { OpenAiCompatibleClient } from '../clients/openai-compatible.client';
import { parseLenientJson } from '../lenient-json';
import type { LlmExtractionFile } from '../llm-provider.interface';
export interface EnforcedExtractInput {
/** Ollama base URL — accepts the addon's `…/v1` form; the `/v1` suffix is stripped. */
@@ -40,6 +44,13 @@ export function toNativeBase(baseUrl: string): string {
return baseUrl.slice(0, end).replace(/\/v1$/, '');
}
/**
* The answers that say the server has no `/api/chat` at all, unless the body is
* Ollama's own 404 for a model it has not pulled (OLLAMA_MISSING_MODEL).
*/
const NOT_OLLAMA_STATUSES = new Set([404, 405, 501]);
const OLLAMA_MISSING_MODEL = /\bmodel\b.*\bnot found\b/i;
/**
* Run one schema-constrained chat completion against Ollama's native `/api/chat`.
* Returns the parsed JSON object (constrained to `schema`), or null if the request
@@ -85,10 +96,61 @@ export async function extractEnforced(input: EnforcedExtractInput): Promise<Reco
if (!res.ok) {
const detail = await res.text().catch(() => '');
if (NOT_OLLAMA_STATUSES.has(res.status) && !OLLAMA_MISSING_MODEL.test(detail))
return extractOpenAiCompatible(input);
throw new Error(`Ollama /api/chat failed (${res.status}): ${detail.slice(0, 200)}`);
}
const data = (await res.json()) as { message?: { content?: string } };
const data = (await res.json().catch(() => null)) as { message?: { content?: string } } | null;
// LM Studio answers a path it does not serve with 200 and an error object,
// so an answer without Ollama's `message` is a server of another kind too.
if (!data || typeof data.message !== 'object') return extractOpenAiCompatible(input);
const parsed = parseLenientJson(data.message?.content);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
}
/**
* The same extraction against an OpenAI-compatible server (Groq, vLLM,
* LM Studio, a LiteLLM proxy), which the "Own server" connection also names.
* It goes to `/chat/completions` at the address as entered, the endpoint the
* assistant chats on, through the client that already handles what such
* servers refuse (`json_schema`, `max_tokens`, `temperature`). The schema is
* asked for as a one-element list, the shape that client reads (#2806).
*/
async function extractOpenAiCompatible(input: EnforcedExtractInput): Promise<Record<string, unknown> | null> {
const [file, ...pageImages] = (input.images ?? []).map(imageFile);
const list = await new OpenAiCompatibleClient().extract({
// The shape goes into the prompt as well: the client's later rungs send no
// schema (json_object, then nothing), and json_object wants the word JSON.
prompt:
`${input.system} Answer with JSON only, as {"result": [object]} where the object follows this ` +
`JSON Schema: ${JSON.stringify(input.schema)}`,
jsonSchema: {
type: 'object',
properties: { result: { type: 'array', items: input.schema } },
required: ['result'],
},
rootKey: 'result',
userText: input.user,
model: input.model,
baseUrl: input.baseUrl,
apiKey: input.apiKey,
file,
pageImages,
});
const first = list[0];
return first && typeof first === 'object' ? first : null;
}
/** The first characters of a base64 image, per type. Anything else is sent as JPEG. */
const IMAGE_PREFIXES: [string, string][] = [
['iVBOR', 'image/png'],
['UklGR', 'image/webp'],
['R0lGOD', 'image/gif'],
];
/** A base64 image as a file, its type read from its first bytes. */
function imageFile(base64: string): LlmExtractionFile {
const mimeType = IMAGE_PREFIXES.find(([prefix]) => base64.startsWith(prefix))?.[1] ?? 'image/jpeg';
return { mimeType, data: Buffer.from(base64, 'base64') };
}
+17 -5
View File
@@ -86,11 +86,16 @@ export async function reclaimPhotoCache(
export interface DedupSet {
names: Set<string>;
/** The names of the places with a line: a track to import matches only these (#2810). Absent where none is read. */
trackNames?: Set<string>;
coords: Array<{ lat: number; lng: number }>;
/** Provider ids (google_place_id, google_ftid, osm_id, amap_poi_id) of the places already in the trip. */
externalIds: Set<string>;
}
/** A candidate for the dedup set; `track` when it is a line (a GPX track or route, a KML path). */
export type DedupCandidate = PlaceMatchCandidate & { track?: boolean };
/**
* Returns true if a candidate place is already represented in the dedup set.
*
@@ -104,12 +109,14 @@ export interface DedupSet {
* JavaScript's is not, and only unnamed rows contribute coordinates to a dedup
* set. `findDuplicatePlace` spells out where the two still answer differently.
*/
export function isPlaceDuplicate(candidate: PlaceMatchCandidate, dedup: DedupSet): boolean {
export function isPlaceDuplicate(candidate: DedupCandidate, dedup: DedupSet): boolean {
for (const strategy of placeMatchStrategies(candidate)) {
if (strategy.by === 'externalId') {
if (dedup.externalIds.has(strategy.id)) return true;
} else if (strategy.by === 'name') {
if (dedup.names.has(strategy.name)) return true;
// A track is the same as a track of that name, never as a sight of that name:
// a watch names a hike after the place it went to (#2810).
if ((candidate.track ? dedup.trackNames : dedup.names)?.has(strategy.name)) return true;
} else if (
dedup.coords.some(
(c) =>
@@ -130,29 +137,34 @@ export function isPlaceDuplicate(candidate: PlaceMatchCandidate, dedup: DedupSet
*/
export function dedupSetOf(
rows: ReadonlyArray<
{ name: string | null; lat: number | null; lng: number | null } & Parameters<typeof externalIdsOf>[0]
{ name: string | null; lat: number | null; lng: number | null; route_geometry?: string | null } & Parameters<
typeof externalIdsOf
>[0]
>,
): DedupSet {
const names = new Set<string>();
const trackNames = new Set<string>();
const coords: Array<{ lat: number; lng: number }> = [];
const externalIds = new Set<string>();
for (const row of rows) {
for (const id of externalIdsOf(row)) externalIds.add(id);
if (row.name) {
names.add(row.name.trim().toLowerCase());
if (row.route_geometry) trackNames.add(row.name.trim().toLowerCase());
} else if (row.lat != null && row.lng != null) {
coords.push({ lat: row.lat, lng: row.lng });
}
}
return { names, coords, externalIds };
return { names, trackNames, coords, externalIds };
}
/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */
export function trackInsertedInDedupSet(place: PlaceMatchCandidate, dedup: DedupSet): void {
export function trackInsertedInDedupSet(place: DedupCandidate, dedup: DedupSet): void {
for (const id of externalIdsOf(place)) dedup.externalIds.add(id);
const normalizedName = normalizePlaceName(place.name);
if (normalizedName) {
dedup.names.add(normalizedName);
if (place.track) (dedup.trackNames ??= new Set()).add(normalizedName);
} else if (place.lat != null && place.lng != null) {
dedup.coords.push({ lat: place.lat, lng: place.lng });
}
+5 -5
View File
@@ -758,7 +758,7 @@ export class PlacesService {
async gpxDuplicates(tripId: string, rows: PreparedGpxPlace[]): Promise<boolean[]> {
const dedup = await this.buildDedupSet(tripId);
return rows.map((wp) => {
const candidate = { name: wp.name, lat: wp.lat, lng: wp.lng };
const candidate = { name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry };
if (isPlaceDuplicate(candidate, dedup)) return true;
trackInsertedInDedupSet(candidate, dedup);
return false;
@@ -925,7 +925,7 @@ export class PlacesService {
// `findWithTagsAndRatings` INSIDE the caller's transaction, so the read
// sees the still-uncommitted row.
for (const wp of waypoints) {
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry }, dedup)) {
skipped++;
continue;
}
@@ -960,7 +960,7 @@ export class PlacesService {
});
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
created.push(place);
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry }, dedup);
}
return { places: created, count: created.length, skipped };
@@ -1033,7 +1033,7 @@ export class PlacesService {
const fallbackName = `Placemark ${fallbackIndex}`;
const name = parsedPlacemark.name || fallbackName;
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup)) {
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath }, dedup)) {
summary.skippedCount += 1;
dupCount++;
fallbackIndex += 1;
@@ -1079,7 +1079,7 @@ export class PlacesService {
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
created.push(place);
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup);
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath }, dedup);
summary.createdCount += 1;
fallbackIndex += 1;
}
+89 -8
View File
@@ -126,18 +126,98 @@ export interface TrekRangeBypass {
/** The plugin's declared range, or null when it declared none at all. */
trekRange: string | null;
hostVersion: string;
/**
* True when an admin accepted this one plugin outside its range, false when
* TREK_PLUGINS_IGNORE_TREK_RANGE lets every plugin through.
*/
acceptedByAdmin: boolean;
}
/** An admin's consent to run one plugin outside its range, as the plugins row keeps it. */
interface TrekRangeAcceptance {
/** The plugin version that was installed when the admin accepted. */
acceptedVersion: string | null;
/** The TREK version it ran on then. */
acceptedHost: string | null;
}
/**
* Non-null exactly when `range` would have been refused and only the bypass lets it
* through: the marker that makes an install/activate response say "this worked, but
* only because you told TREK to ignore the version check". Null for a normal install
* (the range fits) and null when the bypass is off (the gate refuses instead).
* Whether an admin's consent still covers the plugin as it is installed now: the
* same plugin version, on the same TREK major. An update of the plugin, or a TREK
* major past the one the admin saw it run on, asks again.
*/
export function bypassedRange(range: string | null | undefined): TrekRangeBypass | null {
export function acceptanceHolds(
pluginVersion: string | null | undefined,
a: TrekRangeAcceptance | null | undefined,
): boolean {
if (!a?.acceptedVersion || !a.acceptedHost || !pluginVersion) return false;
if (a.acceptedVersion !== pluginVersion) return false;
const accepted = semver.coerce(a.acceptedHost);
const host = normalizedHost();
if (!accepted || host === null) return false;
return semver.major(accepted) === semver.major(host);
}
/**
* Non-null exactly when `range` would have been refused and only a bypass lets it
* through: the marker that makes an install/activate response say "this worked, but
* only because you told TREK to run it anyway". `accepted` is this one plugin's
* consent from the admin; the environment switch covers every plugin. Null for a
* normal install (the range fits) and null when neither applies (the gate refuses).
*/
export function bypassedRange(range: string | null | undefined, accepted = false): TrekRangeBypass | null {
if (hostSatisfies(range)) return null;
if (!trekRangeBypassed()) return null;
return { trekRange: range ?? null, hostVersion: hostVersion() };
const everyPlugin = trekRangeBypassed();
if (!everyPlugin && !accepted) return null;
return { trekRange: range ?? null, hostVersion: hostVersion(), acceptedByAdmin: !everyPlugin };
}
/** What the activation gate reads off a plugin's row to decide whether it may run on this TREK. */
interface TrekRangeRow {
trek_range: string | null;
version: string | null;
trek_range_accepted_version: string | null;
trek_range_accepted_host: string | null;
}
/** Why the activation gate refuses a plugin on this TREK, in the shape its error carries. */
interface TrekRangeRefusal {
message: string;
code: 'TREK_VERSION_UNKNOWN' | 'TREK_VERSION_INCOMPATIBLE';
detail: { trekRange: string | null; hostVersion: string };
}
/**
* The TREK-version half of the activation gate: null when the plugin may run, else why
* not. An admin's consent (`acceptNow`, or one kept on the row for this version and TREK
* major) or TREK_PLUGINS_IGNORE_TREK_RANGE lets an outgrown plugin through, and every
* start of one says so in the log; the plugin list carries the same marker for the UI.
*/
export function trekRangeRefusal(id: string, row: TrekRangeRow, acceptNow = false): TrekRangeRefusal | null {
const accepted =
acceptNow ||
acceptanceHolds(row.version, {
acceptedVersion: row.trek_range_accepted_version,
acceptedHost: row.trek_range_accepted_host,
});
const bypass = bypassedRange(row.trek_range, accepted);
if (bypass) {
warnRangeBypass(id, bypass);
return null;
}
if (!row.trek_range) {
return {
message: `plugin ${id} does not declare which TREK versions it supports`,
code: 'TREK_VERSION_UNKNOWN',
detail: { trekRange: null, hostVersion: hostVersion() },
};
}
if (hostSatisfies(row.trek_range)) return null;
return {
message: `plugin ${id} requires TREK ${row.trek_range} — this is TREK ${hostVersion()}`,
code: 'TREK_VERSION_INCOMPATIBLE',
detail: { trekRange: row.trek_range, hostVersion: hostVersion() },
};
}
/**
@@ -147,7 +227,8 @@ export function bypassedRange(range: string | null | undefined): TrekRangeBypass
*/
export function warnRangeBypass(id: string, b: TrekRangeBypass): void {
const declared = b.trekRange ? `declares TREK ${b.trekRange}` : 'declares no TREK version range';
const because = b.acceptedByAdmin ? 'an admin chose to run it anyway' : 'TREK_PLUGINS_IGNORE_TREK_RANGE is set';
console.warn(
`[plugins] ${id} ${declared} — this is TREK ${b.hostVersion}; continuing because TREK_PLUGINS_IGNORE_TREK_RANGE is set. The author has not verified it on this version: it may not work, and it could corrupt TREK data.`,
`[plugins] ${id} ${declared} — this is TREK ${b.hostVersion}; continuing because ${because}. The author has not verified it on this version: it may not work, and it could corrupt TREK data.`,
);
}
@@ -62,7 +62,7 @@ import { scanForNativeBinaries } from './install/native-scan';
import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link';
import { pluginCodeDir, pluginDataDir } from './paths';
import { assertHostCompatible, PluginRegistryService, RegistryError } from './registry/registry.service';
import { hostSatisfies, hostVersion, bypassedRange, warnRangeBypass } from './install/host-compat';
import { hostSatisfies, hostVersion, trekRangeRefusal } from './install/host-compat';
import type { TrekRangeBypass } from './install/host-compat';
import { keyFingerprint } from './signature-status';
import { AuditService } from '../audit/audit.service';
@@ -639,8 +639,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
* freshly-installed plugin (no prior grant) is itself the consent for its
* declared set; widening an already-consented set requires `consentWiden` (the
* update consent dialog), and otherwise throws PluginConsentRequired.
* `acceptTrekRange` is the admin's "run it anyway" outside the TREK range the author
* declared: for this plugin only, never a dependency, kept on its row for this version.
*/
async activate(id: string, consentWiden = false): Promise<void> {
async activate(id: string, consentWiden = false, acceptTrekRange = false): Promise<void> {
const installed = await this.installedDepRows();
// Deps-first order over the installed graph (throws DependencyCycleError on a
// cycle). Missing deps aren't in `installed` so they don't appear here — the
@@ -652,6 +654,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// blocked dependency never leaves the chain half-activated. Only the target
// may consent-widen; dependencies are auto-enabled at their existing grant.
const toCheck = rootInstalled ? order : [id];
if (acceptTrekRange) await this.rememberRangeAcceptance(id);
for (const nodeId of toCheck) await this.assertActivatable(nodeId, installed, nodeId === id ? consentWiden : false);
// Enable dependencies first (skip ones already enabled), then the target.
@@ -661,6 +664,16 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
}
/**
* Keeps the admin's "run it anyway" for the installed version, when it is in fact outside its range.
* @txStandalone the admin's answer to a dialog; it stands whether or not the plugin then starts.
*/
private async rememberRangeAcceptance(id: string): Promise<void> {
const row = await this.plugins.findActivationGate(id);
if (!row?.version || hostSatisfies(row.trek_range)) return;
await this.plugins.acceptTrekRange(id, row.version, hostVersion());
}
/** All plugin rows projected to what the dependency helpers reason over. */
private async installedDepRows(): Promise<Map<string, PluginDepRow>> {
const rows: PluginDepRow[] = await this.plugins.listDepRows();
@@ -682,24 +695,8 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// it would not make the plugin start. It is also the gate that catches the case
// install can't — TREK was upgraded PAST the plugin's declared upper bound, so code
// that was legitimately installed no longer supports the host it's sitting on.
const bypass = bypassedRange(row.trek_range);
if (bypass) {
// TREK_PLUGINS_IGNORE_TREK_RANGE: the operator chose to run it anyway. Say so in the
// log every time it starts — the plugin list carries the same marker for the UI.
warnRangeBypass(id, bypass);
} else if (!row.trek_range) {
throw new PluginDependencyError(
`plugin ${id} does not declare which TREK versions it supports`,
'TREK_VERSION_UNKNOWN',
{ trekRange: null, hostVersion: hostVersion() },
);
} else if (!hostSatisfies(row.trek_range)) {
throw new PluginDependencyError(
`plugin ${id} requires TREK ${row.trek_range} — this is TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE',
{ trekRange: row.trek_range, hostVersion: hostVersion() },
);
}
const refusal = trekRangeRefusal(id, row);
if (refusal) throw new PluginDependencyError(refusal.message, refusal.code, refusal.detail);
// Same reasoning as the TREK-version gate above: a plugin whose manifest apiVersion
// outpaces this TREK's plugin-API surface can never run correctly, so it must be
// refused before any consent dialog is offered.
+10 -3
View File
@@ -113,8 +113,15 @@ export class PluginsController {
// compatible version and reporting addons the admin still has to enable.
// Dependency resolution pins versions internally but never DELIBERATELY, so
// only the plain path below recomputes the update hold.
if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint);
const res = await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
const acceptTrekRange = body.acceptTrekRange === true;
if (body.withDependencies) {
return await this.registry.installWithDependencies(body.id, body.constraint, acceptTrekRange);
}
const res = await this.registry.install(body.id, {
version: body.version,
constraint: body.constraint,
acceptTrekRange,
});
await this.registry.recomputeUpdateHold(res.id, res.version, !!body.version);
return res;
} catch (e) {
@@ -272,7 +279,7 @@ export class PluginsController {
async activate(@Param('id') id: string, @Body() body: PluginActivateDto) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
try {
await this.runtime.activate(id, !!body?.consent);
await this.runtime.activate(id, !!body?.consent, body?.acceptTrekRange === true);
} catch (e) {
// Re-enabling a plugin whose update widened its permissions must NOT grant
// them silently — surface a distinct code so the UI opens the consent dialog.
+16 -5
View File
@@ -25,7 +25,7 @@ import {
import { devLinkEnabled } from './dev-link';
import { readAudit } from './host/plugin-audit';
import { pluginBudgetUsage } from './host/plugin-host-state';
import { bypassedRange, hostSatisfies, hostVersion, trekRangeBypassed } from './install/host-compat';
import { acceptanceHolds, bypassedRange, hostSatisfies, hostVersion, trekRangeBypassed } from './install/host-compat';
import type { TrekRangeBypass } from './install/host-compat';
import type { PluginDependency } from './install/manifest';
import { pluginsEnabled } from './kill-switch';
@@ -71,6 +71,8 @@ interface PluginRawRow {
capabilities: string;
dependencies: string | null;
trek_range: string | null;
trek_range_accepted_version: string | null;
trek_range_accepted_host: string | null;
author_pubkey: string | null;
update_block_code: string | null;
update_block_detail: string | null;
@@ -113,9 +115,10 @@ export interface PluginListItem {
/** The running TREK, so the UI can say "needs X, you have Y" without doing semver. */
hostVersion: string;
/**
* Non-null when the plugin is outside its declared range (or declared none) and only
* TREK_PLUGINS_IGNORE_TREK_RANGE lets it activate. The card shows it as a warning
* rather than a blocker: the admin chose this, but must keep seeing what they chose.
* Non-null when the plugin is outside its declared range (or declared none) and only an
* admin's "run it anyway" for this plugin, or TREK_PLUGINS_IGNORE_TREK_RANGE, lets it
* activate. The card shows it as a warning rather than a blocker: the admin chose this,
* but must keep seeing what they chose.
*/
trekRangeBypassed: TrekRangeBypass | null;
/** The concrete blockers, so the UI can render chips + the resolve dialog. */
@@ -200,7 +203,13 @@ export class PluginsService {
const state = resolveDependencyState(deps, installed);
// Mirrors the order of assertActivatable's gate, so the card explains the same
// blocker the activate call would hit rather than a second, lesser one.
const trekBypass = bypassedRange(r.trek_range);
const trekBypass = bypassedRange(
r.trek_range,
acceptanceHolds(r.version, {
acceptedVersion: r.trek_range_accepted_version,
acceptedHost: r.trek_range_accepted_host,
}),
);
const dependencyStatus: PluginDependencyStatus =
!hostSatisfies(r.trek_range) && !trekBypass
? 'hostIncompatible'
@@ -213,6 +222,8 @@ export class PluginsService {
dependencies: _raw,
operator_egress: _oe,
trek_range,
trek_range_accepted_version: _acceptedVersion,
trek_range_accepted_host: _acceptedHost,
author_pubkey,
update_block_code,
update_block_detail,
@@ -392,11 +392,11 @@ export class PluginRegistryService {
* version (`minTrekVersion`/`maxTrekVersion`). Throws if the plugin isn't in the
* registry or nothing qualifies. Backs "download the latest compatible version".
*/
async resolveVersion(id: string, constraint?: string): Promise<RegistryVersion> {
async resolveVersion(id: string, constraint?: string, acceptTrekRange = false): Promise<RegistryVersion> {
const reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id);
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
const v = this.latestCompatible(entry, constraint);
const v = this.latestCompatible(entry, constraint) ?? (acceptTrekRange ? this.newestOf(entry, constraint) : null);
if (!v) {
throw new RegistryError(
constraint
@@ -427,6 +427,15 @@ export class PluginRegistryService {
return [...candidates].sort((a, b) => semver.rcompare(a.version, b.version))[0];
}
/**
* The newest version that satisfies `constraint`, whatever TREK it declares: what an
* admin's "install anyway" takes when no version fits this TREK. A version that does
* fit is always preferred over this (see {@link selectVersion}).
*/
private newestOf(entry: RegistryEntry, constraint?: string): RegistryVersion | null {
return this.latestCompatible(entry, constraint, () => true);
}
/**
* Pick the version `install()` will fetch, and refuse an incompatible one — on EVERY
* path, which is the whole point. An explicit `version` and the bare "install latest"
@@ -434,11 +443,15 @@ export class PluginRegistryService {
* through resolveVersion), so the admin UI's own Install button — which sends neither —
* happily installed a plugin that declared it doesn't support this TREK.
*/
private selectVersion(entry: RegistryEntry, opts?: { version?: string; constraint?: string }): RegistryVersion {
private selectVersion(
entry: RegistryEntry,
opts?: { version?: string; constraint?: string; acceptTrekRange?: boolean },
): RegistryVersion {
const accept = opts?.acceptTrekRange === true;
if (opts?.version) {
const ver = entry.versions.find((v) => v.version === opts.version);
if (!ver) throw new RegistryError(`version ${opts.version} not found for ${entry.id}`);
if (!installable(ver, normalizedHost())) {
if (!accept && !installable(ver, normalizedHost())) {
throw new RegistryError(
`${entry.id} ${ver.version} requires TREK ${trekRequirement(ver)} — this is TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE',
@@ -446,7 +459,8 @@ export class PluginRegistryService {
}
return ver;
}
const ver = this.latestCompatible(entry, opts?.constraint);
const ver =
this.latestCompatible(entry, opts?.constraint) ?? (accept ? this.newestOf(entry, opts?.constraint) : null);
if (ver) return ver;
// Nothing compatible. Say WHY against the newest version the admin was actually
// shown, rather than a bare "not found" — the fix is a TREK upgrade, not a retry.
@@ -476,7 +490,7 @@ export class PluginRegistryService {
*/
async install(
id: string,
opts?: { version?: string; constraint?: string; retrustKey?: string },
opts?: { version?: string; constraint?: string; retrustKey?: string; acceptTrekRange?: boolean },
): Promise<{ id: string; version: string; trekRangeBypassed: TrekRangeBypass | null }> {
const reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id);
@@ -518,7 +532,7 @@ export class PluginRegistryService {
// published entries usually carry a lower bound and no upper one at all, so a
// plugin that declares "<4.0.0" passes the pre-download filter on TREK 4 and is
// caught only here.
const trekRangeBypassed = assertHostCompatible(manifest.trekRange, id);
const trekRangeBypassed = assertHostCompatible(manifest.trekRange, id, opts?.acceptTrekRange === true);
if (scanForNativeBinaries(pluginRoot).length) throw new RegistryError('artifact contains native binaries');
// 6. atomic move into place
@@ -543,6 +557,9 @@ export class PluginRegistryService {
// The plugin is now on new code that passed every check — whatever refusal was
// recorded before no longer describes reality.
await clearUpdateBlock(this.plugins, id);
// The admin's "install anyway" for this plugin, kept for the version that landed
// so activating it later does not ask a second time.
if (trekRangeBypassed?.acceptedByAdmin) await this.plugins.acceptTrekRange(id, ver.version, hostVersion());
});
return { id, version: ver.version, trekRangeBypassed };
} finally {
@@ -560,6 +577,7 @@ export class PluginRegistryService {
async installWithDependencies(
id: string,
constraint?: string,
acceptTrekRange = false,
): Promise<{ installed: string[]; requiredAddons: string[]; trekRangeBypassed: TrekRangeBypass | null }> {
let trekRangeBypassed: TrekRangeBypass | null = null;
const installedNow = new Set(await this.plugins.listAllIds());
@@ -570,12 +588,14 @@ export class PluginRegistryService {
const visit = async (pid: string, range: string | undefined, stack: string[]): Promise<void> => {
if (done.has(pid)) return;
if (stack.includes(pid)) throw new RegistryError(`plugin dependency cycle: ${[...stack, pid].join(' -> ')}`);
const ver = await this.resolveVersion(pid, range); // throws if not in registry / unsatisfiable
// The admin's "install anyway" covers the plugin they asked for, never a dependency.
const acceptHere = acceptTrekRange && pid === id;
const ver = await this.resolveVersion(pid, range, acceptHere); // throws if not in registry / unsatisfiable
if (!installedNow.has(pid)) {
// Install the version we just RESOLVED. Passing the range back (or nothing, for
// the root) made install() re-pick on its own and land on entry.versions[0] —
// so the compatible version resolveVersion had chosen was computed and discarded.
const res = await this.install(pid, { version: ver.version });
const res = await this.install(pid, { version: ver.version, acceptTrekRange: acceptHere });
// The admin asked for the root; that is the bypass they are warned about (a
// dependency's own bypass still lands in the log).
if (pid === id) trekRangeBypassed = res.trekRangeBypassed;
@@ -817,13 +837,14 @@ function installable(v: RegistryVersion, host: string | null): boolean {
* every install front door (registry, sideload, dev-link) so they all fail the same way,
* with a code the admin UI can act on rather than prose it would have to string-match.
*
* Under TREK_PLUGINS_IGNORE_TREK_RANGE the refusal becomes a log warning, and the
* returned marker is what the front door hands back so the admin is told too. Null
* means the range fits and nothing was bypassed.
* Under TREK_PLUGINS_IGNORE_TREK_RANGE, or when the admin `accepted` this one plugin
* outside its range, the refusal becomes a log warning, and the returned marker is what
* the front door hands back so the admin is told too. Null means the range fits and
* nothing was bypassed.
*/
export function assertHostCompatible(range: string | null, id: string): TrekRangeBypass | null {
export function assertHostCompatible(range: string | null, id: string, accepted = false): TrekRangeBypass | null {
if (hostSatisfies(range)) return null;
const bypass = bypassedRange(range);
const bypass = bypassedRange(range, accepted);
if (bypass) {
warnRangeBypass(id, bypass);
return bypass;
@@ -70,7 +70,10 @@ export function activityFacts(
}
/** The times an import stores for a track, as the DATETIME columns hold them; NULL without. */
function storedTimes(row: PreparedGpxPlace | undefined): { started: DbTimestamp | null; ended: DbTimestamp | null } {
export function recordedTimesOf(row: PreparedGpxPlace | undefined): {
started: DbTimestamp | null;
ended: DbTimestamp | null;
} {
const times = row?.trackTimes;
return times
? { started: dbNow(new Date(times.startedAt)), ended: dbNow(new Date(times.endedAt)) }
@@ -93,7 +96,7 @@ export function trackTimesFor(
(row, at) => !taken.has(at) && row.name === place.name && row.lat === place.lat && row.lng === place.lng,
);
if (index >= 0) taken.add(index);
byPlace.set(place.id, storedTimes(rows[index]));
byPlace.set(place.id, recordedTimesOf(rows[index]));
}
return byPlace;
}
@@ -0,0 +1,42 @@
import { normalizePlaceName } from '@trek/shared';
/** A place of the trip as the places list reads it: enough to tell a track that is no tour yet. */
export interface TripPlaceForAdoption {
id: number;
name: string;
route_geometry?: string | null;
tour_place_id?: number | null;
}
/** A track of the trip a file row takes over: its place and the line it already holds. */
export interface AdoptedTrack {
id: number;
route_geometry: string;
}
/**
* The rows of a tour import that take over a track already in the trip, by
* row index: a place with a line and no tour yet, under the same name
* (trimmed, any case, the duplicate rule's own reading), one place per row
* and oldest first. A GPX file imported before Tours existed (4.x) left its
* tracks as plain places, and the name alone made every tour import of the
* same file a duplicate, with no tour to show for it (#2810).
*/
export function tracksToAdopt(
rows: ReadonlyArray<{ name: string }>,
places: ReadonlyArray<TripPlaceForAdoption>,
): Map<number, AdoptedTrack> {
const byName = new Map<string, AdoptedTrack[]>();
for (const place of [...places].sort((a, b) => a.id - b.id)) {
const key = normalizePlaceName(place.name);
if (!key || !place.route_geometry || place.tour_place_id != null) continue;
byName.set(key, [...(byName.get(key) ?? []), { id: place.id, route_geometry: place.route_geometry }]);
}
const adopt = new Map<number, AdoptedTrack>();
rows.forEach((row, index) => {
const key = normalizePlaceName(row.name);
const track = key ? byName.get(key)?.shift() : undefined;
if (track) adopt.set(index, track);
});
return adopt;
}
+140 -75
View File
@@ -16,7 +16,7 @@ import { toRowId } from '../common/row-id';
import { UnitOfWork } from '../database/unit-of-work';
import { PlaceImportService } from '../place-import/place-import.service';
import { PlacesService } from '../places/places.service';
import { toActivity, trackTimesFor } from './tour-activities.helpers';
import { recordedTimesOf, toActivity, trackTimesFor } from './tour-activities.helpers';
import {
copiedFacet,
copyStart,
@@ -29,6 +29,7 @@ import { readTourFile } from './tour-file-import.helpers';
import { provenanceSource, tourItemOf } from './tour-item.helpers';
import { TourPaddleCheckService } from './tour-paddle-check.service';
import { advanceTourRevision } from './tour-revision';
import { tracksToAdopt } from './tour-track-adoption';
import { toVisit } from './tour-visits.service';
import { checkTourCommand, firstPointOf, importFileName, keyedWaypoints, tourRouteFields } from './tour-write.helpers';
import { computeTourMetrics, localHashOf, parseRouteGeometry, type GeometryPoint } from './tours.helpers';
@@ -58,6 +59,30 @@ import {
wandererDifficultyToSac,
} from '@trek/shared';
/** `fn` over `items` one after the other, in order: the writes of one import share a transaction. */
async function inOrder<T, R>(items: readonly T[], fn: (item: T) => Promise<R>): Promise<R[]> {
return items.reduce<Promise<R[]>>(async (done, item) => [...(await done), await fn(item)], Promise.resolve([]));
}
/**
* What the type adds to each tour a file import makes: a bike tour and a run
* their profile, a paddle tour its boat and water, a ski track its discipline
* and technique (SKI-02). A run comes in without a pace and a ski track
* without a time: neither is worked out for them, so they stay unknown.
*/
function importedTypeFields(fields: TourGpxImportFields) {
return {
bike_profile: fields.bike_profile ?? null,
run_profile: fields.run_profile ?? null,
run_pace_seconds_per_km: null,
ski_discipline: fields.ski_discipline ?? null,
ski_technique: fields.ski_technique ?? null,
boat_type: fields.boat_type ?? null,
water_context: fields.water_context ?? null,
paddle_speed_kmh: null,
};
}
export interface ImportGpxAsTourResult {
tours: TourListItem[];
caution: boolean;
@@ -783,88 +808,45 @@ export class ToursService {
const { rows } = await readTourFile(this.placeImport, fileBuffer, fileName);
if (rows.length === 0) return null;
await this.assertTourTypeEnabled(fields.tour_type);
// What the type adds to each imported tour: a bike tour and a run their profile, a paddle tour its boat and water,
// a ski track its discipline and technique (SKI-02). A run comes in without a pace and a ski track without a
// time: neither is worked out for them, so they stay unknown.
const typeFields = {
bike_profile: fields.bike_profile ?? null,
run_profile: fields.run_profile ?? null,
run_pace_seconds_per_km: null,
ski_discipline: fields.ski_discipline ?? null,
ski_technique: fields.ski_technique ?? null,
boat_type: fields.boat_type ?? null,
water_context: fields.water_context ?? null,
paddle_speed_kmh: null,
};
const typeFields = importedTypeFields(fields);
const adopt = await this.adoptableTracks(tripId, rows);
const fresh = rows.filter((_, index) => !adopt.has(index));
const tours: TourListItem[] = [];
const adopted: number[] = [];
const result = await this.uow.transactional(async () => {
// A nested transactional, so the places and their facets commit together.
const imported = await this.places.importPreparedGpx(tripId, rows);
const times = trackTimesFor(rows, imported.places);
for (const place of imported.places) {
const recorded = times.get(place.id);
const metrics = computeTourMetrics(parseRouteGeometry(place.route_geometry));
const matchConfidence = metrics.hasElevation ? 1 : 0.3;
await this.toursRepo.insertTour({
place_id: place.id,
tour_type: fields.tour_type,
...typeFields,
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
match_confidence: matchConfidence,
max_hiking_difficulty: null,
origin: 'gpx',
origin_file_name: importFileName(fileName),
track_started_at: recorded?.started ?? null,
track_ended_at: recorded?.ended ?? null,
});
tours.push(
tourItemOf({
place_id: place.id,
name: place.name,
description: place.description ?? null,
website: place.website ?? null,
tour_type: fields.tour_type,
...typeFields,
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: matchConfidence,
max_hiking_difficulty: null,
derived_from_place_id: null,
origin: 'gpx',
portage_speed_kmh: null,
paddle_lake_style: null,
track_marks: null,
has_recorded_track: 0,
planned: 0,
has_waypoints: 0,
stage_ends: 0,
route_missing: 0,
revision: 1,
hiking_preferences: null,
track_started_at: recorded?.started ?? null,
track_ended_at: recorded?.ended ?? null,
}),
);
}
const imported =
fresh.length > 0 ? await this.places.importPreparedGpx(tripId, fresh) : { places: [], count: 0, skipped: 0 };
const times = trackTimesFor(fresh, imported.places);
tours.push(
...(await inOrder(imported.places, (place) =>
this.insertImportedTour(
place,
place.route_geometry ?? null,
times.get(place.id),
fields,
typeFields,
fileName,
),
)),
);
// A track from before Tours keeps its place and its line and becomes the
// tour; the file adds the recorded times 4.x never stored.
await inOrder([...adopt], async ([index, track]) => {
const place = await this.places.get(tripId, String(track.id));
if (!place) return;
const recorded = recordedTimesOf(rows[index]);
tours.push(await this.insertImportedTour(place, track.route_geometry, recorded, fields, typeFields, fileName));
adopted.push(place.id);
});
return imported;
});
if (result.places.length === 0) return { tours: [], caution: false, skipped: result.skipped };
if (tours.length === 0) return { tours: [], caution: false, skipped: result.skipped };
this.broadcastToursChanged(
tripId,
result.places.map((place) => place.id),
tours.map((tour) => tour.place_id),
socketId,
);
for (const place of result.places) {
@@ -874,9 +856,90 @@ export class ToursService {
this.logger.warn(`Committed GPX place ${place.id}: realtime notification failed`);
}
}
// The taken-over places now read as tours; the other clients get the row again.
await inOrder(adopted, async (id) => {
try {
const place = await this.places.get(tripId, String(id));
if (place) this.places.broadcast(tripId, 'place:updated', { place }, socketId);
} catch {
this.logger.warn(`Committed GPX tour on place ${id}: realtime notification failed`);
}
});
return { tours, caution: tours.some((t) => t.caution), skipped: result.skipped };
}
/** Which rows of a file take over a track of the trip from before Tours (#2810), read off the places list. */
private async adoptableTracks(tripId: string, rows: ReadonlyArray<{ name: string }>) {
return tracksToAdopt(rows, await this.places.list(tripId, {}));
}
/**
* The `tours` row for one place of a GPX import, with its metrics read off
* `geometry` and the recorded times, and the list item the import answers
* with. A new place and a track taken over from before Tours (#2810) get the
* same row.
*/
private async insertImportedTour(
place: { id: number; name: string; description?: string | null; website?: string | null },
geometry: string | null,
recorded: ReturnType<typeof recordedTimesOf> | undefined,
fields: TourGpxImportFields,
typeFields: ReturnType<typeof importedTypeFields>,
fileName: string | undefined,
): Promise<TourListItem> {
const metrics = computeTourMetrics(parseRouteGeometry(geometry));
const matchConfidence = metrics.hasElevation ? 1 : 0.3;
await this.toursRepo.insertTour({
place_id: place.id,
tour_type: fields.tour_type,
...typeFields,
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
match_confidence: matchConfidence,
max_hiking_difficulty: null,
origin: 'gpx',
origin_file_name: importFileName(fileName),
track_started_at: recorded?.started ?? null,
track_ended_at: recorded?.ended ?? null,
});
return tourItemOf({
place_id: place.id,
name: place.name,
description: place.description ?? null,
website: place.website ?? null,
tour_type: fields.tour_type,
...typeFields,
distance: metrics.distanceKm,
elevation_gain: metrics.elevationGainM,
elevation_loss: metrics.elevationLossM,
duration: null,
planned_duration_minutes: null,
break_additional_minutes: null,
difficulty: null,
wanderer_ref: null,
match_confidence: matchConfidence,
max_hiking_difficulty: null,
derived_from_place_id: null,
origin: 'gpx',
portage_speed_kmh: null,
paddle_lake_style: null,
track_marks: null,
has_recorded_track: 0,
planned: 0,
has_waypoints: 0,
stage_ends: 0,
route_missing: 0,
revision: 1,
hiking_preferences: null,
track_started_at: recorded?.started ?? null,
track_ended_at: recorded?.ended ?? null,
});
}
/**
* PAD-02: what a file would import, read with the import's own parser and
* duplicate rule and written nowhere. Null when the file holds no track or
@@ -887,6 +950,7 @@ export class ToursService {
const { rows, leftOut } = await readTourFile(this.placeImport, fileBuffer, fileName);
if (rows.length === 0) return null;
const duplicates = await this.places.gpxDuplicates(tripId, rows);
const adopt = await this.adoptableTracks(tripId, rows);
return {
tracks: rows.map((row, index) => {
const geometry = parseRouteGeometry(row.routeGeometry);
@@ -900,7 +964,8 @@ export class ToursService {
elevation_loss_m: metrics.elevationLossM,
started_at: row.trackTimes?.startedAt ?? null,
ended_at: row.trackTimes?.endedAt ?? null,
duplicate: duplicates[index] ?? false,
duplicate: !adopt.has(index) && (duplicates[index] ?? false),
...(adopt.has(index) ? { adopts_track: true } : {}),
};
}),
...(leftOut ? { left_out: leftOut } : {}),
+16 -3
View File
@@ -55,11 +55,18 @@ const tokenListSchema = z.object({
totalItems: z.number().optional(),
});
/** A user record with the ActivityPub actors that stand for it; the local one authors the user's trails. */
const actorSchema = z.object({ id: z.string(), is_local: z.boolean().nullish() });
/**
* A user record with the ActivityPub actors that stand for it; the local one
* authors the user's trails. PocketBase expands a back-relation as a single
* object when only one record can match and as a list otherwise, so an account
* with one actor answers with the object itself.
*/
const userActorsSchema = z.object({
expand: z
.object({
activitypub_actors_via_user: z.array(z.object({ id: z.string(), is_local: z.boolean().nullish() })).nullish(),
activitypub_actors_via_user: z.union([z.array(actorSchema), actorSchema.transform((actor) => [actor])]).nullish(),
})
.nullish(),
});
@@ -259,7 +266,13 @@ export class WandererClient {
const actors = (user.expand?.activitypub_actors_via_user ?? [])
.filter((actor) => actor.is_local !== false && isTrailId(actor.id))
.map((actor) => actor.id);
if (actors.length === 0) throw new WandererError('invalid_response', 'wanderer named no author for the token');
if (actors.length === 0) {
throw new WandererError(
'no_author',
'wanderer named no author for the token',
'no local actor in activitypub_actors_via_user',
);
}
return actors;
}
+7 -1
View File
@@ -2,6 +2,7 @@ import { WandererConnections } from '../../db/entities/WandererConnections.entit
import type { WandererConnectionsRepository } from '../../db/repositories/WandererConnections.repository';
import { decodePolyline, thinLine } from '../../utils/polyline';
import { checkSsrf } from '../../utils/ssrfGuard';
import { logWarn } from '../audit/audit-log.logger';
import { AuditService } from '../audit/audit.service';
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { DomainError } from '../common/domain-error';
@@ -183,9 +184,14 @@ export class WandererService {
await this.connections.recordCheck(userId, ok, code);
}
/** A failed call, with a rejected token noted on the connection so its card says so. */
/**
* A failed call, with a rejected token noted on the connection so its card
* says so. The log gets the reason the client is not shown: which field of
* wanderer's answer did not match, or what came instead of JSON.
*/
private async failed(userId: number, err: unknown): Promise<never> {
if (!(err instanceof WandererError)) throw err;
logWarn(`[wanderer] ${err.code}: ${err.message}${err.detail ? ` (${err.detail})` : ''}`);
if (err.code === 'unauthorized') await this.noteCheck(userId, false, err.code);
throw refusalOf(err);
}
@@ -204,6 +204,16 @@ describe('AI connections e2e', () => {
expect(saved.body.connection).toMatchObject({ scope: 'instance', kind: 'anthropic', hint: '…7777', baseUrl: null });
expect(saved.body.prefill).toBeNull();
// The model for everyone is switched on its own, by an admin only, to one the provider offers (#2783).
const model = (as_: number, body: object) =>
request(server).patch('/api/admin/ai/connection/model').set(as(as_)).send(body);
expect((await model(USER, { model: 'claude-haiku' })).status).toBe(403);
expect((await model(ADMIN, {})).status).toBe(400);
expect((await model(ADMIN, { model: 'nope' })).body).toMatchObject({ code: 'unknown_model' });
const switched = await model(ADMIN, { model: 'claude-haiku' });
expect(switched.status).toBe(200);
expect(switched.body.connection).toMatchObject({ kind: 'anthropic', model: 'claude-haiku', hint: '…7777' });
// A user now runs on the instance's connection, with the daily limit and a privacy notice to acknowledge.
const status = await request(server).get('/api/ai/status').set(as(USER));
expect(status.body).toMatchObject({
+153
View File
@@ -0,0 +1,153 @@
/**
* An "Own server" that is OpenAI-compatible but not Ollama, end to end: the
* whole app against a fake server on loopback that only answers under
* `/openai/v1`, the way Groq does, and 404s every Ollama path.
*
* Covered: connecting it as the instance connection (#2808), the refusal that
* says the server answered without a model list, a refused key, and a booking
* read through it by AI parsing, which used to call Ollama's `/api/chat` and
* fail with the server's 404 (#2806).
*/
import { buildApp } from '../../src/bootstrap';
import { withRequestContext } from '../../src/nest/database/request-context';
import { LlmParseService } from '../../src/nest/llm-parse/llm-parse.service';
import { setAddonEnabled } from '../helpers/factories/settings';
import { makeUser } from '../helpers/factories/users';
import { resetRateLimits } from '../helpers/test-db';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// ── The fake server ──────────────────────────────────────────────────────────
/** How the fake answers: as Groq does, with 200 and a page for every path, or refusing every key. */
const server = { mode: 'groq' as 'groq' | 'html' | 'refuse', seen: [] as string[], chats: [] as unknown[] };
async function readBody(req: IncomingMessage): Promise<string> {
let text = '';
for await (const chunk of req) text += String(chunk);
return text;
}
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
async function answer(req: IncomingMessage, res: ServerResponse): Promise<void> {
server.seen.push(`${req.method} ${req.url}`);
const body = await readBody(req);
if (server.mode === 'refuse') return json(res, 401, { error: { message: 'Invalid API Key' } });
if (server.mode === 'html') {
res.writeHead(200, { 'content-type': 'text/html' });
res.end('<html><body>Sign in</body></html>');
return;
}
if (req.method === 'GET' && req.url === '/openai/v1/models') {
return json(res, 200, { object: 'list', data: [{ id: 'openai/gpt-oss-120b', object: 'model' }] });
}
if (req.method === 'POST' && req.url === '/openai/v1/chat/completions') {
server.chats.push(JSON.parse(body));
const hotel = {
name: 'Hotel Sakura',
address: '1-2-3 Asakusa, Tokyo',
checkin_time: '2026-11-02T15:00:00',
checkout_time: '2026-11-05T11:00:00',
};
const result = { result: [hotel] };
return json(res, 200, { choices: [{ message: { content: JSON.stringify(result) }, finish_reason: 'stop' }] });
}
json(res, 404, {
error: {
message: `Unknown request URL: ${req.method} ${req.url}.`,
type: 'invalid_request_error',
code: 'unknown_url',
},
});
}
// ── The app ──────────────────────────────────────────────────────────────────
let nestApp: INestApplication;
let orm: MikroORM;
let fake: HttpServer;
let adminId: number;
function connect(apiKey = 'gsk_test'): request.Test {
const { port } = fake.address() as AddressInfo;
return request(nestApp.getHttpServer())
.put('/api/admin/ai/connection')
.set('Cookie', sessionCookie(adminId))
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/openai/v1`, apiKey, model: 'openai/gpt-oss-120b' });
}
beforeAll(async () => {
fake = createServer((req, res) => {
answer(req, res).catch(() => res.destroy());
});
await new Promise<void>((resolve) => fake.listen(0, '127.0.0.1', resolve));
nestApp = await buildApp();
orm = nestApp.get(MikroORM);
await setAddonEnabled(orm, 'ai_assistant', true);
adminId = (await makeUser(orm, { role: 'admin' })).user.id;
});
beforeEach(async () => {
await resetRateLimits(nestApp);
server.mode = 'groq';
server.seen = [];
server.chats = [];
});
afterAll(async () => {
await nestApp.close();
await new Promise<void>((resolve) => fake.close(() => resolve()));
});
// ── Cases ────────────────────────────────────────────────────────────────────
describe('An OpenAI-compatible own server', () => {
it('AIOWN-E2E-001: connects, its models read from the list at the address as entered (#2808)', async () => {
const res = await connect();
expect(res.status).toBe(200);
expect(server.seen.slice(0, 2)).toEqual(['GET /openai/api/tags', 'GET /openai/v1/models']);
});
it('AIOWN-E2E-002: a server that answers without a model list is told apart from one that is down', async () => {
server.mode = 'html';
// Its own key, so the list the first case read is not taken from the catalog's cache.
const res = await connect('gsk_html');
expect(res.status).toBe(502);
expect(res.body).toMatchObject({ code: 'provider_unavailable', reason: 'no_model_list' });
});
it('AIOWN-E2E-003: a refused key reads as a refused key, not as a server that is down', async () => {
server.mode = 'refuse';
const res = await connect('wrong');
expect(res.status).toBe(422);
expect(res.body).toMatchObject({ code: 'invalid_key' });
});
it('AIOWN-E2E-004: AI parsing reads a booking through /chat/completions when /api/chat is missing (#2806)', async () => {
expect((await connect()).status).toBe(200);
await setAddonEnabled(orm, 'llm_parsing', true);
server.seen = [];
// Outside a request, so it needs its own ORM context, as a job would.
const booking = {
buffer: Buffer.from('Booking confirmation\nHotel Sakura, Tokyo\nCheck-in 2 Nov 2026, check-out 5 Nov 2026'),
originalName: 'booking.txt',
};
const out = await withRequestContext(orm, () => nestApp.get(LlmParseService).parse(booking, adminId));
expect(out.warnings.filter((w) => /failed/i.test(w))).toEqual([]);
expect(out.kiItems).toHaveLength(1);
expect(server.seen).toContain('POST /openai/api/chat');
expect(server.seen).toContain('POST /openai/v1/chat/completions');
const sent = server.chats[0] as { model: string; messages: { role: string }[] };
expect(sent.model).toBe('openai/gpt-oss-120b');
});
});
+45
View File
@@ -535,6 +535,51 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
expect(await countRows(orm, Tours)).toBe(1);
});
it('takes over a track from before Tours with the same name instead of skipping it (#2810)', async () => {
// 4.x stored a GPX track as a plain place: a line, no tours row.
const line = '[[49,12,100],[49.01,12.01,150],[49.02,12.02,120]]';
const legacy = (await makePlace(orm, 5, { name: 'Alpine Loop ', lat: 49, lng: 12, route_geometry: line })).id;
const elsewhere = (await makePlace(orm, 6, { name: 'Alpine loop', lat: 49, lng: 12, route_geometry: line })).id;
const preview = await previewGpx(OWNER, TRACK_GPX);
expect(preview.status).toBe(200);
expect(preview.body.tracks[0]).toMatchObject({ duplicate: false, adopts_track: true });
expect(await countRows(orm, Tours)).toBe(0);
const res = await importGpx(OWNER, TRACK_GPX);
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ caution: false, skipped: 0 });
expect(res.body.tours.map((tour: { place_id: number }) => tour.place_id)).toEqual([legacy]);
expect(await countRows(orm, Places, { trip: 5 })).toBe(1);
expect(await placeColumns(legacy, 'name', 'route_geometry')).toEqual({
name: 'Alpine Loop ',
route_geometry: line,
});
expect(await findRow(orm, Tours, { place: legacy })).toMatchObject({ tour_type: 'hike', origin: 'gpx' });
expect(await findRow(orm, Tours, { place: elsewhere })).toBeNull();
expect(broadcast.mock.calls.map((c) => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:updated']));
expect(broadcast.mock.calls.map((c) => c[1])).not.toContain('place:created');
// Now a tour, the same file is a duplicate again.
expect((await previewGpx(OWNER, TRACK_GPX)).body.tracks[0]).toMatchObject({ duplicate: true });
expect((await importGpx(OWNER, TRACK_GPX)).body).toEqual({ tours: [], caution: false, skipped: 1 });
});
it('imports a hike named after a sight of the trip instead of skipping it as a duplicate (#2810)', async () => {
// A watch names the hike after where it went, and that place is already planned.
const sight = (await makePlace(orm, 5, { name: 'alpine loop', lat: 47, lng: 11 })).id;
const preview = await previewGpx(OWNER, TRACK_GPX);
expect(preview.body.tracks[0]).toMatchObject({ name: 'Alpine loop', duplicate: false });
const res = await importGpx(OWNER, TRACK_GPX);
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ skipped: 0 });
expect(res.body.tours).toHaveLength(1);
expect(res.body.tours[0].place_id).not.toBe(sight);
expect(await placeColumns(sight, 'route_geometry')).toEqual({ route_geometry: null });
});
it('400 without a file, and for a file with no track or route', async () => {
const missing = await importGpx(OWNER, null);
expect(missing.status).toBe(400);

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