Compare commits

...

26 Commits

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

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

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

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

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

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

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

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

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

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

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

AI parsing through an own server always called Ollama's /api/chat, which an
OpenAI-compatible server such as Groq answers with 404. A server without it
now gets the same extraction over /chat/completions at the address as
entered (#2806).
2026-10-26 10:20:46 +01:00
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
239 changed files with 3731 additions and 868 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"src": { "src": {
"@typescript-eslint/no-non-null-assertion": 382, "@typescript-eslint/no-non-null-assertion": 382,
"@typescript-eslint/no-misused-promises": 849, "@typescript-eslint/no-misused-promises": 848,
"no-console": 88, "no-console": 88,
"complexity": 220, "complexity": 220,
"@typescript-eslint/no-explicit-any": 146, "@typescript-eslint/no-explicit-any": 146,
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"admin.ts": 126, "admin.ts": 112,
"atlas.ts": 9, "atlas.ts": 9,
"backup.ts": 10, "backup.ts": 10,
"budget.ts": 36, "budget.ts": 36,
+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/BudgetPanelMemberChips.tsx": 6,
"components/Budget/CostsPanel.tsx": 3, "components/Budget/CostsPanel.tsx": 3,
"components/Budget/ReceiptPreviewModal.tsx": 2, "components/Budget/ReceiptPreviewModal.tsx": 2,
+1 -1
View File
@@ -2,7 +2,7 @@
"e2e/help/fixtures.ts": 2291, "e2e/help/fixtures.ts": 2291,
"src/api/client.ts": 1873, "src/api/client.ts": 1873,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704, "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.test.tsx": 2736,
"src/components/Budget/CostsPanel.tsx": 1798, "src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1385, "src/components/Map/MapView.tsx": 1385,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"App.tsx": 11, "App.tsx": 11,
"components/Admin/AdminPluginsPanel.tsx": 2,
"components/Admin/DefaultUserSettingsTab.tsx": 1, "components/Admin/DefaultUserSettingsTab.tsx": 1,
"components/Admin/plugins/pluginMenu.ts": 2,
"components/Atlas/AtlasCountryPlaces.tsx": 6, "components/Atlas/AtlasCountryPlaces.tsx": 6,
"components/BackgroundTasks/BackgroundTasksWidget.tsx": 4, "components/BackgroundTasks/BackgroundTasksWidget.tsx": 4,
"components/Budget/BudgetPanel.helpers.ts": 2, "components/Budget/BudgetPanel.helpers.ts": 2,
+1
View File
@@ -692,6 +692,7 @@ describe('client > request payloads', () => {
it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => { 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'))).body).toEqual({})
expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true }) 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 () => { 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), 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), 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), 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), 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), 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 // `version` pins the exact version to install (the rollback path); omitted, the server
// resolves the newest TREK-compatible version itself. // resolves the newest TREK-compatible version itself.
@@ -489,10 +489,10 @@ describe('AdminPluginsPanel — TREK-version compatibility', () => {
fireEvent.click(await screen.findByText('Discover')) 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 }) await openDiscover({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
const btn = await screen.findByRole('button', { name: /^incompatible$/i }) expect(await screen.findByText('Not confirmed for TREK 3.3.0 yet')).toBeInTheDocument()
expect(btn).toBeDisabled() expect(screen.getByRole('button', { name: /^install$/i })).toBeEnabled()
}) })
it('offers the newest version that DOES run here rather than a dead button', async () => { 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. */ /** Opens one of the toolbar dropdowns (identified by its current title) and picks an option. */
async function pickFilter(trigger: RegExp, option: RegExp) { /** The toolbar's one Filter button; a pick in its menu leaves the menu open. */
fireEvent.click(await screen.findByTitle(trigger)) const filterButton = (): HTMLElement => screen.getByRole('button', { name: /^Filter/ })
fireEvent.click(await screen.findByRole('button', { name: option })) 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. */ /** The enable toggle of a named installed row. */
@@ -610,9 +612,9 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
render(<AdminPluginsPanel />) render(<AdminPluginsPanel />)
await screen.findByText('Gotify') 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.getByText('Countdown')).toBeInTheDocument()
expect(screen.queryByText('Gotify')).not.toBeInTheDocument() expect(screen.queryByText('Gotify')).not.toBeInTheDocument()
}) })
@@ -626,15 +628,15 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
render(<AdminPluginsPanel />) render(<AdminPluginsPanel />)
await screen.findByText('OnOne') await screen.findByText('OnOne')
await pickFilter(/^Status: All$/, /^Off$/) await pickFilter(/^Off$/)
expect(await screen.findByText('OffOne')).toBeInTheDocument() expect(await screen.findByText('OffOne')).toBeInTheDocument()
expect(screen.queryByText('OnOne')).not.toBeInTheDocument() expect(screen.queryByText('OnOne')).not.toBeInTheDocument()
await pickFilter(/^Status: Off$/, /^Error$/) await pickFilter(/^Error$/)
expect(await screen.findByText('BadOne')).toBeInTheDocument() expect(await screen.findByText('BadOne')).toBeInTheDocument()
expect(screen.getByText('boom')).toBeInTheDocument() expect(screen.getByText('boom')).toBeInTheDocument()
await pickFilter(/^Status: Error$/, /^Update available$/) await pickFilter(/^Update available$/)
expect(await screen.findByText('OnOne')).toBeInTheDocument() expect(await screen.findByText('OnOne')).toBeInTheDocument()
expect(screen.queryByText('BadOne')).not.toBeInTheDocument() expect(screen.queryByText('BadOne')).not.toBeInTheDocument()
}) })
@@ -644,11 +646,10 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
render(<AdminPluginsPanel />) render(<AdminPluginsPanel />)
await screen.findByText('Gotify') await screen.findByText('Gotify')
await pickFilter(/^Sort: Name$/, /^Updates first$/) await pickFilter(/^Updates first$/)
expect(await screen.findByTitle('Sort: Updates first')).toBeInTheDocument() expect(filterButton()).toHaveTextContent('Updates first')
await clickDiscover() 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 () => { 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', () => { describe('AdminPluginsPanel — security dialog', () => {
it('FE-COMP-PLUGINS-PANEL-048: the containment note expands into its sections', async () => { it('FE-COMP-PLUGINS-PANEL-048: the shield in the header opens how plugins are contained', async () => {
panelWith([]) panelWith([])
render(<AdminPluginsPanel />) render(<AdminPluginsPanel />)
await screen.findByText('No plugins installed yet.') await screen.findByText('No plugins installed yet.')
expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument() 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('Every plugin runs boxed in')).toBeInTheDocument()
expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument() expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument()
expect(screen.getByText('What "Signed" 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' }), 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) => { const pickFilter = async (user: ReturnType<typeof userEvent.setup>, option: RegExp) => {
await user.click(screen.getByTitle(filter)) if (filterButton().getAttribute('aria-expanded') !== 'true') await user.click(filterButton())
await user.click(screen.getByRole('button', { name: option })) await user.click(screen.getByRole('menuitemradio', { name: option }))
} }
it('FE-W5PLG-001: the search box narrows the installed list by name and description', async () => { 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 />) render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget') await screen.findByText('Alpha Widget')
await pickFilter(user, /^Type:/, /^Widget$/) await pickFilter(user, /^Widget$/)
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument()) await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
expect(screen.getByText('Alpha Widget')).toBeInTheDocument() expect(screen.getByText('Alpha Widget')).toBeInTheDocument()
@@ -1746,15 +1748,15 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
render(<AdminPluginsPanel />) render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget') await screen.findByText('Alpha Widget')
await pickFilter(user, /^Status:/, /^Active$/) await pickFilter(user, /^Active$/)
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument()) await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
expect(screen.queryByText('Gamma Page')).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()) await waitFor(() => expect(screen.getByText('Beta Bridge')).toBeInTheDocument())
expect(screen.queryByText('Alpha Widget')).not.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()) await waitFor(() => expect(screen.getByText('Gamma Page')).toBeInTheDocument())
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument() expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
}) })
@@ -1766,13 +1768,13 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
await screen.findByText('Alpha Widget') await screen.findByText('Alpha Widget')
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument()) await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
await pickFilter(user, /^Sort:/, /^Updates first$/) await pickFilter(user, /^Updates first$/)
await waitFor(() => { await waitFor(() => {
const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map(e => e.textContent) const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map(e => e.textContent)
expect(names[0]).toBe('Gamma Page') 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()) await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument())
expect(screen.getByText('Gamma Page')).toBeInTheDocument() expect(screen.getByText('Gamma Page')).toBeInTheDocument()
}) })
@@ -1783,11 +1785,10 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
render(<AdminPluginsPanel />) render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget') await screen.findByText('Alpha Widget')
await pickFilter(user, /^Sort:/, /^Updates first$/) await pickFilter(user, /^Updates first$/)
expect(screen.getByTitle(/^Sort: Updates first$/)).toBeInTheDocument() expect(filterButton()).toHaveTextContent('Updates first')
await user.click(screen.getByRole('tab', { name: /Discover/ })) 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 () => { 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 user.click(screen.getByRole('tab', { name: /Discover/ }))
await screen.findByText('Old Tool') await screen.findByText('Old Tool')
await pickFilter(user, /^Sort:/, /^Most downloads$/) await pickFilter(user, /^Most downloads$/)
await waitFor(() => { await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent) const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool') expect(names[0]).toBe('New Tool')
}) })
await pickFilter(user, /^Sort:/, /^Recently updated$/) await pickFilter(user, /^Recently updated$/)
await waitFor(() => { await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent) const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool') expect(names[0]).toBe('New Tool')
}) })
await pickFilter(user, /^Sort:/, /^Name$/) await pickFilter(user, /^Name$/)
await waitFor(() => { await waitFor(() => {
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent) const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
expect(names[0]).toBe('New Tool') expect(names[0]).toBe('New Tool')
@@ -1833,7 +1834,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
await user.click(screen.getByRole('tab', { name: /Discover/ })) await user.click(screen.getByRole('tab', { name: /Discover/ }))
await screen.findByText('Mapper') await screen.findByText('Mapper')
await pickFilter(user, /^Type:/, /^Page$/) await pickFilter(user, /^Page$/)
await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument()) await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument())
expect(screen.getByText('Poster')).toBeInTheDocument() expect(screen.getByText('Poster')).toBeInTheDocument()
@@ -1962,12 +1963,12 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
const { container } = render(<AdminPluginsPanel />) const { container } = render(<AdminPluginsPanel />)
await screen.findByText('Alpha Widget') await screen.findByText('Alpha Widget')
await user.click(screen.getByTitle(/^Type:/)) await user.click(filterButton())
expect(screen.getByRole('button', { name: /^Widget$/ })).toBeInTheDocument() expect(screen.getByRole('menuitemradio', { name: /^Widget$/ })).toBeInTheDocument()
const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement
fireEvent.click(layer) 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 screen.findByText('Alpha Widget')
await openDiscover(user) await openDiscover(user)
await user.click(screen.getByTitle(/^Sort:/)) await user.click(filterButton())
await user.click(screen.getByRole('button', { name: /^Most downloads$/ })) await user.click(screen.getByRole('menuitemradio', { name: /^Most downloads$/ }))
expect(screen.getByTitle(/^Sort: Most downloads$/)).toBeInTheDocument() expect(filterButton()).toHaveTextContent('Most downloads')
await user.click(screen.getByRole('tab', { name: /Installed/ })) 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 () => { 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).not.toBeDisabled()
expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0')) expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0'))
const dead = screen.getByRole('button', { name: /^Incompatible$/ }) // Nothing fits: no dead button any more, but a card that says so before anything installs.
expect(dead).toBeDisabled() expect(screen.getByText(/^Not confirmed for TREK .+ yet$/)).toBeInTheDocument()
expect(dead.getAttribute('title')).toBeTruthy()
}) })
it('FE-W5PLG-018: an already installed registry entry cannot be installed again', async () => { 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() 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 let posted: unknown = null
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry()) bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => { 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() withToast()
fireEvent.click(await screen.findByText('Discover')) 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() expect(btn).toBeEnabled()
fireEvent.click(btn) 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 }) })) server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
withToast() withToast()
fireEvent.click(await screen.findByText('Discover')) 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 }) const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/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 { useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { import {
Blocks, AlertTriangle, RefreshCw, Trash2, Download, Bug, ShieldCheck, UploadCloud, Blocks, AlertTriangle, RefreshCw, Trash2, Download, Bug, ShieldCheck, UploadCloud,
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, ArrowUpCircle, Github, ExternalLink, Check, Search, Link2, KeyRound, ShieldAlert,
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database, SlidersHorizontal, CircleDot, Database,
Globe, Info, History, PauseCircle, Puzzle, Bot, Loader2, Globe, History, Puzzle, Bot, Loader2,
} from 'lucide-react' } from 'lucide-react'
import PluginIcon from '../shared/PluginIcon' import PluginIcon from '../shared/PluginIcon'
import { useInstanceSettings } from './useInstanceSettings' import { useInstanceSettings } from './useInstanceSettings'
@@ -23,15 +22,17 @@ import {
SettingsCard, SettingRows, SettingRow, SettingsHint, StatusPill, SettingsCard, SettingRows, SettingRow, SettingsHint, StatusPill,
SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON,
} from '../Settings/settingsKit' } from '../Settings/settingsKit'
import { POPOVER } from '../Packing/packingPopoverStyles'
import { import {
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, deriveDeps, fingerprint, formatCompactCount, KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, fingerprint, formatCompactCount,
installOffer, isRegistrySourced, parseJson, signatureBlockView, sortLabel, statusLabel, installOffer, signatureBlockView,
type PluginDep, type PluginRow, type RegistryItem, type SigSubject, type SortKey, type PluginDep, type PluginRow, type RegistryItem, type SigSubject,
type StatusFilter, type T, type TypeFilter, type VersionInfo, type VersionMismatch, type T, type VersionInfo, type VersionMismatch,
} from './plugins/pluginModel' } from './plugins/pluginModel'
import { usePluginDetail } from './plugins/usePluginDetail' import { usePluginDetail } from './plugins/usePluginDetail'
import { usePluginsAdmin } from './plugins/usePluginsAdmin' 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 * 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. * 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. */ /** 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)' const WARN_TINT = 'color-mix(in srgb, var(--warning) 10%, transparent)'
/** The eyebrow over a block, the size the dialogs' sections use. */ /** 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' 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. */ /** 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_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' 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. */ /** A pill that explains itself on hover. */
function HintPill({ hint, children }: { hint: string; children: ReactNode }) { function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
return ( 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). * 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 }) { function TypeBadge({ type, t }: { type: string; t: T }) {
return ( 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)}> <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, sort, statusFilter, toggle, typeFilter, uninstallConfirmed, updatable, updateAll, updateAvailable, uploadInputRef,
uploadPlugin, versionPicker, view, uploadPlugin, versionPicker, view,
} = usePluginsAdmin() } = usePluginsAdmin()
const [securityOpen, setSecurityOpen] = useState(false)
return ( return (
<div className="relative mb-24 sm:mb-0" <div className="relative mb-24 sm:mb-0"
@@ -181,6 +143,9 @@ export default function AdminPluginsPanel() {
) : undefined} ) : undefined}
action={ready ? ( 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'}> <BandButton label={t('admin.plugins.upload')} onClick={pickUpload} disabled={busy === '__upload'}>
<UploadCloud size={15} strokeWidth={2} /> <UploadCloud size={15} strokeWidth={2} />
</BandButton> </BandButton>
@@ -242,31 +207,9 @@ export default function AdminPluginsPanel() {
/> />
</label> </label>
<FilterMenu id="type" label={t('admin.plugins.filterType')} value={typeFilter} menu={menu} setMenu={setMenu} icon={<SlidersHorizontal size={14} />} <PluginFilterMenu t={t} view={view} typeFilter={typeFilter} setTypeFilter={setTypeFilter}
options={[ statusFilter={statusFilter} setStatusFilter={setStatusFilter} sort={sort} setSort={setSort}
['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')], menu={menu} setMenu={setMenu} />
['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)} />
</div> </div>
{view === 'discover' ? ( {view === 'discover' ? (
@@ -299,7 +242,7 @@ export default function AdminPluginsPanel() {
) : ( ) : (
<SettingRows> <SettingRows>
{shownInstalled.map(p => ( {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]} hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
newerIncompatible={newerIncompatible(p)} newerIncompatible={newerIncompatible(p)}
blocked={blockIsCurrent(p, latest[p.id])} blocked={blockIsCurrent(p, latest[p.id])}
@@ -320,7 +263,7 @@ export default function AdminPluginsPanel() {
)} )}
</SettingsCard> </SettingsCard>
<SecurityInfo t={t} /> {securityOpen && <PluginSecurityDialog t={t} onClose={() => setSecurityOpen(false)} />}
{/* Registry detail dialog */} {/* Registry detail dialog */}
{detailFor && ( {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 }) { function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) {
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
return ( 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> <StatusPill icon={<ShieldCheck size={11} className="text-success" />}>{t('admin.plugins.reviewed')}</StatusPill>
</span> </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"> <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" /> <PluginIcon name={item.icon} size={20} className="text-content-secondary" />
</span> </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 * 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. * component rather than ConfirmDialog because the notice mode has no cancel button.
*/ */
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: { function RangeBypassDialog({ copy: { title, body, confirm, confirmLabel }, onConfirm, onClose, t }: {
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T copy: { title: string; body: string; confirm: boolean; confirmLabel: string }; onConfirm: () => void; onClose: () => void; t: T
}) { }) {
return ( return (
<DialogShell <DialogShell
@@ -844,7 +535,7 @@ function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose,
{confirm ? ( {confirm ? (
<> <>
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton> <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> <DialogButton variant="primary" onClick={onClose}>{t('common.ok')}</DialogButton>
@@ -1495,45 +1186,3 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
</DialogShell> </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>
)
}
@@ -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, blocked: false,
version: '0.9.0', version: '0.9.0',
}); });
expect(installOffer(incompatible, t)).toMatchObject({ blocked: true, label: 'admin.plugins.incompatible' }); // Nothing fits this TREK: still an Install, behind the admin's own warning for this plugin.
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, label: 'admin.plugins.installAnyway' }); 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', () => { it('FE-ADMIN-PLUGIN-MODEL-008: deriveDeps marks the blockers among the declared dependencies', () => {
@@ -298,7 +298,8 @@ export function installOffer(
title, 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 { act, renderHook, waitFor } from '@testing-library/react';
import type { DragEvent } from 'react'; import type { DragEvent } from 'react';
@@ -266,4 +266,36 @@ describe('usePluginsAdmin', () => {
expect(toast.success).toHaveBeenCalledWith('admin.plugins.uninstalled'); expect(toast.success).toHaveBeenCalledWith('admin.plugins.uninstalled');
expect(result.current.confirmUninstall).toBeNull(); 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( act(
id, id,
() => () =>
adminApi.pluginInstall(id, version ? { version } : undefined).then((r) => { adminApi
if (!warn) bypass.notice(id, r?.trekRangeBypassed); .pluginInstall(id, {
}), ...(version ? { version } : {}),
...(warn?.acceptedByAdmin ? { acceptTrekRange: true } : {}),
})
.then((r) => {
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
}),
t('admin.plugins.installed') 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); const cascaded = autoEnabledDeps(p);
return adminApi return adminApi
.pluginActivate(p.id) .pluginActivate(p.id, false, acceptTrekRange)
.then(() => { .then(() => {
toast.success(t('admin.plugins.activated')); toast.success(t('admin.plugins.activated'));
if (cascaded.length) if (cascaded.length)
@@ -415,12 +420,29 @@ export function usePluginsAdmin() {
void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated'));
return; return;
} }
setBusy(p.id); const run = (acceptTrekRange: boolean) => {
setMenu(null); setBusy(p.id);
attemptActivate(p).finally(() => { setMenu(null);
setBusy(null); attemptActivate(p, acceptTrekRange).finally(() => {
refresh(); 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, // 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"}', 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' }) 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') const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
expect(offer).toEqual({ expect(offer).toEqual({
blocked: false, label: 'admin.plugins.installAnyway', title: 'why', blocked: false, label: 'admin.plugins.install', title: 'why',
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null }, 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').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', () => { it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
@@ -32,10 +40,25 @@ describe('rangeBypass helpers', () => {
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({ expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
confirm: true, title: 'admin.plugins.rangeBypass.title', confirm: true, title: 'admin.plugins.rangeBypass.title',
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}', 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({ expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle', 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 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 } * The server's "this only went through because the version check was set aside" marker:
/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */ * by an admin for this one plugin (`acceptedByAdmin`), or for every plugin through
export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null } * 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 * 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) { export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
if (!b) return null 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 { return {
icon: AlertTriangle, icon: AlertTriangle,
label: b.trekRange label,
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
: t('admin.plugins.dep.trekBypassedUnknown'),
blocked: false, blocked: false,
warn: true, warn: true,
} }
} }
/** /**
* The Install button for a registry entry the server flagged incompatible, once the * The Install button for a registry entry the server flagged incompatible: the server will
* operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version * take the newest version regardless, so the button says exactly that — and carries what
* regardless, so the button says exactly that — and carries what the warning has to say * the warning has to say first. `byAdmin` is the admin's own "install anyway" for this one
* first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y). * 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) { export function bypassOffer(
return { item: { name: string; trek?: string | null; hostVersion?: string },
blocked: false, t: T,
label: t('admin.plugins.installAnyway'), title: string,
title, byAdmin = false
warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning, ) {
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 * whole point: the admin is accepting that the author never vouched for this TREK and
* that, in rare cases, that can cost them data. * 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 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 { return {
confirm, confirm,
title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'), title,
body: w.trekRange body,
? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion }) confirmLabel: w.action === 'activate' ? t('admin.plugins.activateAnyway') : t('admin.plugins.installAnyway'),
: t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }),
} }
} }
@@ -440,6 +440,8 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
currency: { labelKey: 'assistant.change.label.currency' }, currency: { labelKey: 'assistant.change.label.currency' },
stopType: { labelKey: 'assistant.change.label.stopType' }, stopType: { labelKey: 'assistant.change.label.stopType' },
text: { labelKey: 'assistant.change.label.text' }, text: { labelKey: 'assistant.change.label.text' },
title: { labelKey: 'assistant.change.label.title' },
content: { labelKey: 'assistant.change.label.content' },
icon: { labelKey: 'assistant.change.label.icon' }, icon: { labelKey: 'assistant.change.label.icon' },
color: { labelKey: 'assistant.change.label.color' }, color: { labelKey: 'assistant.change.label.color' },
position: { labelKey: 'assistant.change.label.position' }, position: { labelKey: 'assistant.change.label.position' },
@@ -1,4 +1,4 @@
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-030 // FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-032
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import en from '@trek/shared/i18n/en' import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render' import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
@@ -66,6 +66,7 @@ function renderCard(
canManage?: boolean canManage?: boolean
providerName?: string providerName?: string
onReconnect?: () => void onReconnect?: () => void
onEditConnection?: () => void
} = {}, } = {},
) { ) {
const utils = render( const utils = render(
@@ -75,6 +76,7 @@ function renderCard(
sync={makeSync(opts.sync)} sync={makeSync(opts.sync)}
canManage={opts.canManage ?? true} canManage={opts.canManage ?? true}
onReconnect={opts.onReconnect} onReconnect={opts.onReconnect}
onEditConnection={opts.onEditConnection}
/>, />,
) )
const header = utils.container.querySelector('header') as HTMLElement const header = utils.container.querySelector('header') as HTMLElement
@@ -421,3 +423,31 @@ describe('DocSyncBinding: the direction lanes', () => {
expect(updateLink).not.toHaveBeenCalled() expect(updateLink).not.toHaveBeenCalled()
}) })
}) })
describe('DocSyncBinding: the connection behind it', () => {
const S = en as unknown as Record<string, string>
const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] })
it('FE-DOCSYNC-BIND-031: the settings open the stored connection at any time, not only after a refusal', () => {
const onEditConnection = vi.fn()
renderCard({ link: { lastSyncState: 'ok' }, onEditConnection })
expect(editButton()).not.toBeInTheDocument()
openSettings()
expect(screen.getByText(S['docsync.connection.title'])).toBeInTheDocument()
fireEvent.click(editButton() as HTMLElement)
expect(onEditConnection).toHaveBeenCalledTimes(1)
})
it('FE-DOCSYNC-BIND-032: a member, or a panel without the form, gets no way to the connection', () => {
const member = renderCard({ canManage: false, onEditConnection: vi.fn() })
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
expect(editButton()).not.toBeInTheDocument()
member.unmount()
renderCard()
openSettings()
expect(editButton()).not.toBeInTheDocument()
})
})
@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { import {
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, Link2Off, RefreshCw, AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, KeyRound, Link2Off, RefreshCw,
} from 'lucide-react' } from 'lucide-react'
import ConfirmDialog from '../../shared/ConfirmDialog' import ConfirmDialog from '../../shared/ConfirmDialog'
import CustomSelect from '../../shared/CustomSelect' import CustomSelect from '../../shared/CustomSelect'
@@ -27,6 +27,7 @@ export default function DocSyncBinding({
sync, sync,
canManage, canManage,
onReconnect, onReconnect,
onEditConnection,
}: { }: {
link: DocSyncLink link: DocSyncLink
providerName: string providerName: string
@@ -38,6 +39,8 @@ export default function DocSyncBinding({
* not carry; left out, a refused credential is reported but not curable. * not carry; left out, a refused credential is reported but not curable.
*/ */
onReconnect?: () => void onReconnect?: () => void
/** The same form at any time, to change the address or the login, or to remove the connection. */
onEditConnection?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -237,6 +240,21 @@ export default function DocSyncBinding({
</div> </div>
</div> </div>
)} )}
{onEditConnection && (
<Row label={t('docsync.connection.title')} hint={t('docsync.connection.hint')}>
<span className="flex justify-end">
<button
type="button"
onClick={onEditConnection}
className="flex items-center gap-1.5 rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
<KeyRound size={13} />
{t('docsync.connection.edit')}
</button>
</span>
</Row>
)}
</div> </div>
)} )}
</> </>
@@ -1,4 +1,4 @@
// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-027 // FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-030
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en' import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor, act, within } from '../../../../tests/helpers/render' import { render, screen, fireEvent, waitFor, act, within } from '../../../../tests/helpers/render'
@@ -13,6 +13,7 @@ const listLinks = vi.fn(async (_tripId: number | string): Promise<unknown> => []
const status = vi.fn(async (_tripId: number | string): Promise<unknown> => ({ items: {}, links: [] })) const status = vi.fn(async (_tripId: number | string): Promise<unknown> => ({ items: {}, links: [] }))
const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ id: 1 })) const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ id: 1 }))
const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true })) const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
const deleteConnection = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
vi.mock('../../../api/client', async (importOriginal) => { vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>() const actual = await importOriginal<typeof import('../../../api/client')>()
@@ -26,6 +27,7 @@ vi.mock('../../../api/client', async (importOriginal) => {
listLinks: (tripId: number | string) => listLinks(tripId), listLinks: (tripId: number | string) => listLinks(tripId),
saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data), saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data),
testConnection: (tripId: number | string, data: unknown) => testConnection(tripId, data), testConnection: (tripId: number | string, data: unknown) => testConnection(tripId, data),
deleteConnection: (tripId: number | string, id: number) => deleteConnection(tripId, id),
}, },
} }
}) })
@@ -81,11 +83,13 @@ function Harness({
provider, provider,
onClose, onClose,
onConnected, onConnected,
onRemoved,
waitForConnection, waitForConnection,
}: { }: {
provider: DocSyncProvider provider: DocSyncProvider
onClose: () => void onClose: () => void
onConnected: (providerId: string) => void onConnected: (providerId: string) => void
onRemoved?: () => void
/** /**
* Hold the dialog back until the stored connection is in hand, the way the * Hold the dialog back until the stored connection is in hand, the way the
* panel does: it only offers "connect" once it knows what is already there. * panel does: it only offers "connect" once it knows what is already there.
@@ -94,7 +98,9 @@ function Harness({
}) { }) {
const sync = useDocSync(TRIP_ID, true) const sync = useDocSync(TRIP_ID, true)
if (waitForConnection && !sync.connectionFor(provider.id)) return null if (waitForConnection && !sync.connectionFor(provider.id)) return null
return <DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} /> return (
<DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} onRemoved={onRemoved} />
)
} }
/** Let the hook's initial load land before anything is asserted. */ /** Let the hook's initial load land before anything is asserted. */
@@ -492,3 +498,52 @@ describe('DocSyncConnectModal with a connection already stored', () => {
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ baseUrl: 'https://cloud.example.com' })) expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ baseUrl: 'https://cloud.example.com' }))
}) })
}) })
describe('DocSyncConnectModal removal', () => {
const removeButton = () => screen.queryByRole('button', { name: tx('docsync.connection.remove') })
// The confirmation is portalled after the dialog, so its button is the last one of that name.
const confirmRemoval = () => {
const named = screen.getAllByRole('button', { name: tx('docsync.connection.remove') })
return named[named.length - 1]
}
async function openWithRemoval(stored: boolean) {
if (stored) listConnections.mockResolvedValue([connection()])
const onRemoved = vi.fn()
render(<Harness provider={nextcloud} onClose={vi.fn()} onConnected={vi.fn()} onRemoved={onRemoved} waitForConnection={stored} />)
await flush()
return onRemoved
}
it('FE-DOCSYNC-CONNECT-028: a stored connection can be removed after a confirmation, and the dialog is told', async () => {
const onRemoved = await openWithRemoval(true)
await waitFor(() => expect(removeButton()).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: tx('docsync.connection.remove') }))
expect(deleteConnection).not.toHaveBeenCalled()
await screen.findByText(tx('docsync.connection.confirmRemove'))
fireEvent.click(confirmRemoval())
await waitFor(() => expect(deleteConnection).toHaveBeenCalledWith(TRIP_ID, 9))
await waitFor(() => expect(onRemoved).toHaveBeenCalled())
})
it('FE-DOCSYNC-CONNECT-029: a refused removal keeps the dialog and says why', async () => {
deleteConnection.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
const onRemoved = await openWithRemoval(true)
await waitFor(() => expect(removeButton()).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: tx('docsync.connection.remove') }))
await screen.findByText(tx('docsync.connection.confirmRemove'))
fireEvent.click(confirmRemoval())
await waitFor(() => expect(deleteConnection).toHaveBeenCalled())
expect(onRemoved).not.toHaveBeenCalled()
expect(await screen.findByText(tx('docsync.error.forbidden'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-030: a first connection has nothing to remove', async () => {
await openWithRemoval(false)
expect(removeButton()).not.toBeInTheDocument()
})
})
@@ -1,6 +1,7 @@
import { useId, useState } from 'react' import { useId, useState } from 'react'
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react' import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell' import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
import ConfirmDialog from '../../shared/ConfirmDialog'
import { Tooltip } from '../../shared/Tooltip' import { Tooltip } from '../../shared/Tooltip'
import ToggleSwitch from '../../Settings/ToggleSwitch' import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useTranslation } from '../../../i18n/TranslationContext' import { useTranslation } from '../../../i18n/TranslationContext'
@@ -22,11 +23,14 @@ export default function DocSyncConnectModal({
sync, sync,
onClose, onClose,
onConnected, onConnected,
onRemoved,
}: { }: {
provider: DocSyncProvider provider: DocSyncProvider
sync: ReturnType<typeof useDocSync> sync: ReturnType<typeof useDocSync>
onClose: () => void onClose: () => void
onConnected: (providerId: string) => void onConnected: (providerId: string) => void
/** After the stored connection was removed from here; left out, the form offers no removal. */
onRemoved?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const existing = sync.connectionFor(provider.id) const existing = sync.connectionFor(provider.id)
@@ -34,6 +38,11 @@ export default function DocSyncConnectModal({
const form = useConnectForm(provider, existing, sync) const form = useConnectForm(provider, existing, sync)
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null) const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
const [confirmRemove, setConfirmRemove] = useState(false)
const remove = async () => {
if (existing && (await sync.removeConnection(existing.id))) onRemoved?.()
}
const test = async () => setVerdict(await form.probe()) const test = async () => setVerdict(await form.probe())
const labelId = useId() const labelId = useId()
@@ -46,6 +55,7 @@ export default function DocSyncConnectModal({
<DialogShell <DialogShell
onClose={onClose} onClose={onClose}
labelledBy={labelId} labelledBy={labelId}
blocked={confirmRemove}
header={( header={(
<DialogHeader <DialogHeader
tile={<DialogTile>{Icon ? <Icon className="h-5 w-5 text-content" /> : <Plug size={20} strokeWidth={1.9} className="text-content-muted" />}</DialogTile>} tile={<DialogTile>{Icon ? <Icon className="h-5 w-5 text-content" /> : <Plug size={20} strokeWidth={1.9} className="text-content-muted" />}</DialogTile>}
@@ -57,6 +67,13 @@ export default function DocSyncConnectModal({
)} )}
footer={( footer={(
<DialogFooter> <DialogFooter>
{existing && onRemoved && (
<DeleteButton
label={t('docsync.connection.remove')}
onClick={() => setConfirmRemove(true)}
disabled={sync.busy === 'connection'}
/>
)}
<Verdict verdict={verdict} busy={sync.busy === 'test'} failure={sync.error} /> <Verdict verdict={verdict} busy={sync.busy === 'test'} failure={sync.error} />
<FooterSpacer /> <FooterSpacer />
<DialogButton onClick={() => void test()} disabled={!form.complete || sync.busy === 'test'}> <DialogButton onClick={() => void test()} disabled={!form.complete || sync.busy === 'test'}>
@@ -101,6 +118,15 @@ export default function DocSyncConnectModal({
</span> </span>
</label> </label>
</div> </div>
<ConfirmDialog
isOpen={confirmRemove}
onClose={() => setConfirmRemove(false)}
onConfirm={() => void remove()}
title={t('docsync.connection.remove')}
message={t('docsync.connection.confirmRemove')}
confirmLabel={t('docsync.connection.remove')}
/>
</DialogShell> </DialogShell>
) )
} }
@@ -62,6 +62,9 @@ export default function DocSyncPanel({
setSelected(prev => (sync.links.some(l => l.id === prev) ? prev : (sync.links[0]?.id ?? null))) setSelected(prev => (sync.links.some(l => l.id === prev) ? prev : (sync.links[0]?.id ?? null)))
}, [sync.links]) }, [sync.links])
// The connection behind the folder picker, which the picker can send back to.
const scopeProvider = scopeFor ? sync.providers.find(p => p.id === scopeFor) : undefined
const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0) const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0)
const labelId = useId() const labelId = useId()
@@ -113,6 +116,7 @@ export default function DocSyncPanel({
sync={sync} sync={sync}
canManage={canManage} canManage={canManage}
onReconnect={activeProvider ? () => setReconnecting(activeProvider) : undefined} onReconnect={activeProvider ? () => setReconnecting(activeProvider) : undefined}
onEditConnection={canManage && activeProvider ? () => setReconnecting(activeProvider) : undefined}
/> />
{/* Every action in this column reports here: a refused run, a {/* Every action in this column reports here: a refused run, a
switch that flipped back, a conflict the server would not switch that flipped back, a conflict the server would not
@@ -141,6 +145,7 @@ export default function DocSyncPanel({
sync={sync} sync={sync}
onClose={() => setConnecting(null)} onClose={() => setConnecting(null)}
onConnected={id => { setConnecting(null); setScopeFor(id) }} onConnected={id => { setConnecting(null); setScopeFor(id) }}
onRemoved={() => setConnecting(null)}
/> />
)} )}
@@ -149,6 +154,7 @@ export default function DocSyncPanel({
provider={reconnecting} provider={reconnecting}
sync={sync} sync={sync}
onClose={() => setReconnecting(null)} onClose={() => setReconnecting(null)}
onRemoved={() => setReconnecting(null)}
onConnected={id => { onConnected={id => {
setReconnecting(null) setReconnecting(null)
// Run straight away, as binding does: the card still reports the // Run straight away, as binding does: the card still reports the
@@ -168,6 +174,7 @@ export default function DocSyncPanel({
sync={sync} sync={sync}
onClose={() => setScopeFor(null)} onClose={() => setScopeFor(null)}
onBound={() => setScopeFor(null)} onBound={() => setScopeFor(null)}
onEditConnection={scopeProvider ? () => { setScopeFor(null); setConnecting(scopeProvider) } : undefined}
/> />
)} )}
</> </>
@@ -1,4 +1,4 @@
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-023 // FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-024
/** /**
* The folder picker of the document-sync dialog. * The folder picker of the document-sync dialog.
@@ -84,9 +84,10 @@ function deferred<T>() {
return { promise, resolve, reject } return { promise, resolve, reject }
} }
function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }: { function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless', onEditConnection }: {
suggestedName?: string suggestedName?: string
providerName?: string providerName?: string
onEditConnection?: () => void
}) { }) {
const sync = useDocSync(TRIP_ID, false) const sync = useDocSync(TRIP_ID, false)
return ( return (
@@ -97,6 +98,7 @@ function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }
sync={sync} sync={sync}
onClose={onClose} onClose={onClose}
onBound={onBound} onBound={onBound}
onEditConnection={onEditConnection}
/> />
) )
} }
@@ -381,3 +383,20 @@ describe('DocSyncScopeModal errors', () => {
expect(createButton()).toBeEnabled() expect(createButton()).toBeEnabled()
}) })
}) })
describe('DocSyncScopeModal connection', () => {
it('FE-DOCSYNC-SCOPE-024: leads back to the stored connection before a folder is chosen', async () => {
const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] })
const onEditConnection = vi.fn()
const first = render(<Harness onEditConnection={onEditConnection} />)
await waitFor(() => expect(listScopes).toHaveBeenCalled())
fireEvent.click(editButton() as HTMLElement)
expect(onEditConnection).toHaveBeenCalledTimes(1)
expect(createLink).not.toHaveBeenCalled()
first.unmount()
render(<Harness />)
expect(editButton()).not.toBeInTheDocument()
})
})
@@ -1,5 +1,5 @@
import { useId, useMemo, useState } from 'react' import { useId, useMemo, useState } from 'react'
import { FolderPlus, Loader2, Search } from 'lucide-react' import { FolderPlus, KeyRound, Loader2, Search } from 'lucide-react'
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell' import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell'
import { useTranslation } from '../../../i18n/TranslationContext' import { useTranslation } from '../../../i18n/TranslationContext'
import type { DocSyncConnection, useDocSync } from './useDocSync' import type { DocSyncConnection, useDocSync } from './useDocSync'
@@ -22,6 +22,7 @@ export default function DocSyncScopeModal({
sync, sync,
onClose, onClose,
onBound, onBound,
onEditConnection,
}: { }: {
connection: DocSyncConnection connection: DocSyncConnection
providerName: string providerName: string
@@ -30,6 +31,8 @@ export default function DocSyncScopeModal({
sync: ReturnType<typeof useDocSync> sync: ReturnType<typeof useDocSync>
onClose: () => void onClose: () => void
onBound: () => void onBound: () => void
/** Back to the stored connection, to change the login or remove it before choosing a folder. */
onEditConnection?: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const labelId = useId() const labelId = useId()
@@ -63,7 +66,19 @@ export default function DocSyncScopeModal({
)} )}
> >
<div className="space-y-4"> <div className="space-y-4">
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p> <div className="flex items-start justify-between gap-3">
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
{onEditConnection && (
<button
type="button"
onClick={onEditConnection}
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-edge px-2.5 py-1 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
>
<KeyRound size={13} />
{t('docsync.connection.edit')}
</button>
)}
</div>
<div className="rounded-xl border border-edge bg-surface-secondary p-3"> <div className="rounded-xl border border-edge bg-surface-secondary p-3">
<span className="mb-2 block text-body font-medium text-content">{t('docsync.scope.createTitle')}</span> <span className="mb-2 block text-body font-medium text-content">{t('docsync.scope.createTitle')}</span>
@@ -1,4 +1,4 @@
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-030 // FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-032
/** /**
* The document-sync hook. * The document-sync hook.
@@ -22,6 +22,7 @@ const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown)
const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise<unknown> => ({})) const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise<unknown> => ({}))
const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({})) const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({}))
const listScopesApi = vi.fn(async (_tripId: number | string, _connectionId: number, _q?: string): Promise<unknown> => ({ scopes: [] })) const listScopesApi = vi.fn(async (_tripId: number | string, _connectionId: number, _q?: string): Promise<unknown> => ({ scopes: [] }))
const deleteConnectionApi = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
const resolveApi = vi.fn(async (_tripId: number | string, _itemId: number, _keep: string): Promise<unknown> => ({ success: true })) const resolveApi = vi.fn(async (_tripId: number | string, _itemId: number, _keep: string): Promise<unknown> => ({ success: true }))
vi.mock('../../../api/client', async (importOriginal) => { vi.mock('../../../api/client', async (importOriginal) => {
@@ -40,6 +41,7 @@ vi.mock('../../../api/client', async (importOriginal) => {
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full), syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full),
listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopesApi(tripId, connectionId, q), listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopesApi(tripId, connectionId, q),
resolve: (tripId: number | string, itemId: number, keep: string) => resolveApi(tripId, itemId, keep), resolve: (tripId: number | string, itemId: number, keep: string) => resolveApi(tripId, itemId, keep),
deleteConnection: (tripId: number | string, id: number) => deleteConnectionApi(tripId, id),
}, },
} }
}) })
@@ -126,6 +128,7 @@ beforeEach(() => {
syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }) syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
listScopesApi.mockResolvedValue({ scopes: [] }) listScopesApi.mockResolvedValue({ scopes: [] })
resolveApi.mockResolvedValue({ success: true }) resolveApi.mockResolvedValue({ success: true })
deleteConnectionApi.mockResolvedValue({ success: true })
}) })
describe('useDocSync initial load', () => { describe('useDocSync initial load', () => {
@@ -471,6 +474,38 @@ describe('useDocSync resolveConflict', () => {
}) })
}) })
describe('useDocSync removeConnection', () => {
it('FE-DOCSYNC-HOOK-031: forgets the connection and reloads, so its bindings leave the screen too', async () => {
const { result } = await mountLoaded()
expect(result.current.links).toHaveLength(1)
// The server drops the bindings with the connection (ON DELETE CASCADE).
listConnectionsApi.mockResolvedValue([])
listLinksApi.mockResolvedValue([])
let ok: boolean | undefined
await act(async () => { ok = await result.current.removeConnection(9) })
expect(ok).toBe(true)
expect(deleteConnectionApi).toHaveBeenCalledWith(TRIP, 9)
expect(result.current.connectionFor('nextcloud')).toBeNull()
expect(result.current.links).toEqual([])
expect(result.current.busy).toBeNull()
})
it('FE-DOCSYNC-HOOK-032: a refused removal keeps everything and says why', async () => {
deleteConnectionApi.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
const { result } = await mountLoaded()
let ok: boolean | undefined
await act(async () => { ok = await result.current.removeConnection(9) })
expect(ok).toBe(false)
expect(result.current.error).toBe('forbidden')
expect(result.current.connectionFor('nextcloud')).not.toBeNull()
expect(result.current.busy).toBeNull()
})
})
describe('needsReauth', () => { describe('needsReauth', () => {
it('FE-DOCSYNC-HOOK-030: reads a refused credential off either the state or the error code', () => { it('FE-DOCSYNC-HOOK-030: reads a refused credential off either the state or the error code', () => {
expect(needsReauth({ lastSyncState: 'needs_reauth', lastSyncError: null })).toBe(true) expect(needsReauth({ lastSyncState: 'needs_reauth', lastSyncError: null })).toBe(true)
@@ -264,6 +264,27 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
} }
}, [tripId, load]) }, [tripId, load])
/**
* Forget a stored connection, and with it every binding of this trip that
* runs through it. The documents stay on both sides; what goes is the
* address and the login TREK kept for the store, which is the one thing a
* person changing accounts or folders had no way to take back.
*/
const removeConnection = useCallback(async (connectionId: number) => {
setBusy('connection')
setError(null)
try {
await docsyncApi.deleteConnection(tripId, connectionId)
await load()
return true
} catch (e: unknown) {
setError(readError(e))
return false
} finally {
setBusy(null)
}
}, [tripId, load])
/** /**
* Probe without saving. Always resolves to a verdict object: the route * Probe without saving. Always resolves to a verdict object: the route
* answers 200 even for an unreachable instance, because a form showing a typo * answers 200 even for an unreachable instance, because a form showing a typo
@@ -427,7 +448,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
connections, links, itemCounts, connections, links, itemCounts,
loading, busy, error, loading, busy, error,
connectionFor, load, connectionFor, load,
saveConnection, testConnection, saveConnection, testConnection, removeConnection,
loadScopes, createScope, loadScopes, createScope,
createLink, updateLink, removeLink, syncNow, resolveConflict, createLink, updateLink, removeLink, syncNow, resolveConflict,
} }
@@ -485,6 +485,17 @@ describe('KategorieGruppe — inline add item', () => {
expect(onAddItem).not.toHaveBeenCalled() 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', () => { it('FE-W5CAT-028: the confirm button adds the item, and stays inert while empty', () => {
const onAddItem = vi.fn(async () => {}) const onAddItem = vi.fn(async () => {})
const { container } = setup({ onAddItem }) const { container } = setup({ onAddItem })
@@ -86,6 +86,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
const [showAddItem, setShowAddItem] = useState(false) const [showAddItem, setShowAddItem] = useState(false)
const [newItemName, setNewItemName] = useState('') const [newItemName, setNewItemName] = useState('')
const addItemRef = useRef<HTMLInputElement>(null) const addItemRef = useRef<HTMLInputElement>(null)
const openAddItem = () => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }
const menuBtnRef = useRef<HTMLButtonElement>(null) const menuBtnRef = useRef<HTMLButtonElement>(null)
const assigneeDropdownRef = useRef<HTMLDivElement>(null) const assigneeDropdownRef = useRef<HTMLDivElement>(null)
const { togglePackingItem } = useTripStore() const { togglePackingItem } = useTripStore()
@@ -292,7 +293,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
const contributions = contribFor(item.id) const contributions = contribFor(item.id)
return ( return (
<React.Fragment key={item.id}> <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} tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave}
drag={canEdit && reorderable ? { drag={canEdit && reorderable ? {
isDragging: dragId === item.id, 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 // 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 // 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. // 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' }} 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)'} onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}> onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
@@ -257,6 +257,23 @@ describe('ArtikelZeile — renaming', () => {
await waitFor(() => expect(screen.getByText('...')).toBeInTheDocument()) 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 () => { 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 }))) server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
setup() setup()
@@ -25,6 +25,8 @@ interface ArtikelZeileProps {
categories: string[] categories: string[]
onCategoryChange: () => void onCategoryChange: () => void
onDelete?: (item: PackingItem) => Promise<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 bagTrackingEnabled?: boolean
bags?: PackingBag[] bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined> 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 isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name) 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 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 } 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')) } catch { toast.error(t('packing.toast.saveError')) }
} }
@@ -188,8 +193,8 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
type="text" value={editName} autoFocus type="text" value={editName} autoFocus
placeholder={isPlaceholder ? '...' : undefined} placeholder={isPlaceholder ? '...' : undefined}
onChange={e => setEditName(e.target.value)} onChange={e => setEditName(e.target.value)}
onBlur={handleSaveName} onBlur={() => void handleSaveName()}
onKeyDown={e => { if (e.key === 'Enter') void handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }} 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' }} 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 ? ( ) : canEdit ? (
@@ -803,6 +803,34 @@ describe('useRoadtripRoutes', () => {
} }
}) })
it('FE-ROADTRIP-ROUTES-052: a join this device routed before is not paced like a request (#2794)', async () => {
// A join kept its request slot whatever answered it, so a month of connected days
// waited half a minute on every open with every road already on the device. The
// clock is held still, so every answer reads as one from the cache; a join that
// still waited its turn would wait 1.1 s of real time, past what waitFor gives it.
const clock = vi.spyOn(performance, 'now').mockImplementation(() => 10_000)
try {
const MUNICH: [number, number] = [48.1374, 11.5755]
const INNSBRUCK: [number, number] = [47.2692, 11.4041]
const BOZEN: [number, number] = [46.4983, 11.3548]
setting({ roadtrip_connect_days: true })
const daysList = [day(1, 1), day(2, 2), day(3, 3)]
const assignments = {
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
...map(2, [{ id: 3, at: BERLIN, time: '09:00' }, { id: 4, at: MUNICH }]),
...map(3, [{ id: 5, at: INNSBRUCK, time: '09:00' }, { id: 6, at: BOZEN }]),
} as AssignmentsMap
renderHook(() => useRoadtripRoutes(7, daysList, assignments))
await waitFor(() => {
expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1)
expect(askedFor(MUNICH, INNSBRUCK)).toHaveLength(1)
})
} finally {
clock.mockRestore()
}
})
it('FE-ROADTRIP-ROUTES-051: a join OSRM answered in the engine’s place is drawn, flagged, and asked for again on request', async () => { it('FE-ROADTRIP-ROUTES-051: a join OSRM answered in the engine’s place is drawn, flagged, and asked for again on request', async () => {
// Filed like any answer, it stood in for the weighed road until the page was loaded // Filed like any answer, it stood in for the weighed road until the page was loaded
// again, and a picker on it read the stand-in line as the road already taken. // again, and a picker on it read the stand-in line as the road already taken.
@@ -692,11 +692,19 @@ export function useRoadtripRoutes(
* for both, so a choice being checked cannot fire into the gap a seam is keeping. * for both, so a choice being checked cannot fire into the gap a seam is keeping.
*/ */
const lastSpacedRequestAt = useRef(0) const lastSpacedRequestAt = useRef(0)
/** Waits until the gap since the last such request is kept, then claims the next slot. */ /**
const spacedRequest = useCallback(async (signal: AbortSignal): Promise<void> => { * Waits until the gap since the last such request is kept, then claims the next slot.
* Hands back what gives the slot up again, for a request the cache answered.
*/
const spacedRequest = useCallback(async (signal: AbortSignal): Promise<() => void> => {
const since = performance.now() - lastSpacedRequestAt.current const since = performance.now() - lastSpacedRequestAt.current
if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, signal) if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, signal)
if (!signal.aborted) lastSpacedRequestAt.current = performance.now() const before = lastSpacedRequestAt.current
const claimed = performance.now()
if (!signal.aborted) lastSpacedRequestAt.current = claimed
return () => {
if (lastSpacedRequestAt.current === claimed) lastSpacedRequestAt.current = before
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -727,7 +735,7 @@ export function useRoadtripRoutes(
// shared routing hosts answered the tail with 429, and a refused seam // shared routing hosts answered the tail with 429, and a refused seam
// writes no state, so nothing changed to make the effect try again: the // writes no state, so nothing changed to make the effect try again: the
// map drew the trip in pieces and the totals came back short. // map drew the trip in pieces and the totals came back short.
await spacedRequest(controller.signal) const release = await spacedRequest(controller.signal)
if (controller.signal.aborted) return if (controller.signal.aborted) return
try { try {
// The via points on this seam, threaded in the same way the day runs thread // The via points on this seam, threaded in the same way the day runs thread
@@ -735,6 +743,7 @@ export function useRoadtripRoutes(
// shape: it is asked for on its own, so the points the traveller dropped on it // shape: it is asked for on its own, so the points the traveller dropped on it
// never reached the router and dragging one did visibly nothing. // never reached the router and dragging one did visibly nothing.
const shaping = viasOnLeg(seam.from, seam.to, viasByDay[seam.from.ownerDayId] ?? []) const shaping = viasOnLeg(seam.from, seam.to, viasByDay[seam.from.ownerDayId] ?? [])
const startedAt = performance.now()
const r = await calculateRouteWithLegs( const r = await calculateRouteWithLegs(
[ [
{ lat: seam.from.lat, lng: seam.from.lng }, { lat: seam.from.lat, lng: seam.from.lng },
@@ -743,6 +752,10 @@ export function useRoadtripRoutes(
], ],
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: seam.dayId, avoid }, { signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: seam.dayId, avoid },
) )
// A seam this device routed before came back from its cache and went nowhere, so
// its slot goes back before the store below re-runs this effect. Holding it made a
// trip of connected days wait a second per join on every open (#2794).
if (performance.now() - startedAt <= CACHE_HIT_MS) release()
if (controller.signal.aborted) return if (controller.signal.aborted) return
// One leg per waypoint PAIR, so a shaped seam comes back in pieces and the rail // One leg per waypoint PAIR, so a shaped seam comes back in pieces and the rail
// wants the whole drive: merged here, exactly as a day run folds its via legs // wants the whole drive: merged here, exactly as a day run folds its via legs
@@ -47,6 +47,14 @@ describe('aiConnectModel', () => {
expect(connectErrorKey(new Error('boom'))).toBe('assistant.error.generic'); 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', () => { 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)).toBeNull();
expect(repairModeOf({ ...VIEW, status: 'error' })).toBe('key'); expect(repairModeOf({ ...VIEW, status: 'error' })).toBe('key');
@@ -33,9 +33,20 @@ const CONNECT_ERRORS: Record<string, { messageKey: string }> = {
offline: { messageKey: 'assistant.error.offline' }, 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 { 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. */ /** How each connection state reads on its pill. */
@@ -1,4 +1,4 @@
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-003 // FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-004
// The GPX preview as ACT-01 uses it: preset to the tour's type, a city walk // 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. // imported as one, and when a track was recorded shown where it carried times.
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
@@ -47,6 +47,13 @@ describe('TourGpxPreviewDialog (ACT-01)', () => {
expect(recorded[0]).toHaveTextContent('50 min'); 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', () => { it('FE-GPX-PREVIEW-002: imports as the preset type, a paddle tour with its boat and water', () => {
const current = gpxImport([track()]); const current = gpxImport([track()]);
render( render(
@@ -79,6 +79,7 @@ function PreviewTrackRow({
{track.name || name} {track.name || name}
</span> </span>
{track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>} {track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>}
{track.adopts_track && <SoftPill>{t('tours.import.previewAdopts')}</SoftPill>}
</div> </div>
<div className="flex flex-wrap items-center gap-1.5 ps-8" style={fs(11)}> <div className="flex flex-wrap items-center gap-1.5 ps-8" style={fs(11)}>
<span className={BADGE}>{formatDistance(track.distance_km, unit)}</span> <span className={BADGE}>{formatDistance(track.distance_km, unit)}</span>
@@ -294,7 +294,7 @@ export default function MAdminPluginsPanel() {
{bypass.copy && ( {bypass.copy && (
<MConfirmSheet open onClose={bypass.dismiss} onConfirm={bypass.copy.confirm ? bypass.confirm : undefined} <MConfirmSheet open onClose={bypass.dismiss} onConfirm={bypass.copy.confirm ? bypass.confirm : undefined}
title={bypass.copy.title} message={bypass.copy.body} 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 */} {/* Row ⋯ action sheet */}
@@ -1,4 +1,4 @@
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-024 // FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-027
import type { ComponentProps } from 'react' import type { ComponentProps } from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor, within } from '../../../../../tests/helpers/render' import { render, screen, fireEvent, waitFor, within } from '../../../../../tests/helpers/render'
@@ -137,6 +137,12 @@ beforeEach(() => {
vi.spyOn(docsyncApi, 'createLink').mockResolvedValue({}) vi.spyOn(docsyncApi, 'createLink').mockResolvedValue({})
vi.spyOn(docsyncApi, 'items').mockResolvedValue([]) vi.spyOn(docsyncApi, 'items').mockResolvedValue([])
vi.spyOn(docsyncApi, 'resolve').mockResolvedValue({ success: true }) vi.spyOn(docsyncApi, 'resolve').mockResolvedValue({ success: true })
vi.spyOn(docsyncApi, 'deleteConnection').mockImplementation(async (_tripId, id) => {
// The bindings go with their connection, as ON DELETE CASCADE has it.
connections = connections.filter(c => c.id !== id)
links = links.filter(l => l.connectionId !== id)
return { success: true }
})
}) })
/** The status route with a number of conflicts on it, holdings as usual. */ /** The status route with a number of conflicts on it, holdings as usual. */
@@ -528,3 +534,45 @@ describe('MDocSyncSheet: what a member may do', () => {
expect(screen.queryByRole('button', { name: /Papra/ })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /Papra/ })).not.toBeInTheDocument()
}) })
}) })
describe('MDocSyncSheet: the stored connection', () => {
const removeButtons = () => screen.getAllByRole('button', { name: 'Remove connection' })
it('FE-DOCSYNC-MOBILE-025: a binding opens its connection at any time, and removing it asks first', async () => {
renderSheet()
await openDetail()
fireEvent.click(screen.getByRole('button', { name: 'Edit connection' }))
expect(await screen.findByPlaceholderText('https://paperless.example')).toHaveValue('https://paperless.example')
fireEvent.click(removeButtons()[0])
expect(docsyncApi.deleteConnection).not.toHaveBeenCalled()
expect(await screen.findByText(/TREK forgets the address and login/)).toBeInTheDocument()
// The confirmation sheet opens over the form, so its button is the last one of that name.
const named = removeButtons()
fireEvent.click(named[named.length - 1])
await waitFor(() => expect(docsyncApi.deleteConnection).toHaveBeenCalledWith(TRIP_ID, 5))
// Back at the list, with Paperless offered again from scratch.
expect(await screen.findByText('Connect a provider')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-MOBILE-026: a stored connection without a binding can be edited from the folder picker', async () => {
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: /Nextcloud/ }))
await screen.findByText('Photos')
fireEvent.click(screen.getByRole('button', { name: 'Edit connection' }))
expect(await screen.findByRole('button', { name: 'Remove connection' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it('FE-DOCSYNC-MOBILE-027: a member sees no way to the connection', async () => {
renderSheet({ canManage: false })
await openDetail()
expect(screen.queryByRole('button', { name: 'Edit connection' })).not.toBeInTheDocument()
})
})
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { import {
ArrowLeft, ArrowRight, Check, ChevronLeft, ChevronRight, FolderPlus, FolderSync, KeyRound, Link2Off, ArrowLeft, ArrowRight, Check, ChevronLeft, ChevronRight, FolderPlus, FolderSync, KeyRound, Link2Off,
Loader2, Plus, RefreshCw, Loader2, Plus, RefreshCw, Trash2,
} from 'lucide-react' } from 'lucide-react'
import MSheet from '../../../components/MSheet' import MSheet from '../../../components/MSheet'
import MIconBtn from '../../../components/MIconBtn' import MIconBtn from '../../../components/MIconBtn'
@@ -149,6 +149,7 @@ export default function MDocSyncSheet({
provider={pending} provider={pending}
sync={sync} sync={sync}
onDone={connected} onDone={connected}
onRemoved={() => { setReconnectFor(null); setPending(null); setView('list') }}
/> />
) : view === 'scope' && pending && sync.connectionFor(pending.id) ? ( ) : view === 'scope' && pending && sync.connectionFor(pending.id) ? (
<ScopeView <ScopeView
@@ -156,6 +157,7 @@ export default function MDocSyncSheet({
suggestedName={slugFor(tripTitle, tripId)} suggestedName={slugFor(tripTitle, tripId)}
sync={sync} sync={sync}
onBound={() => setView('list')} onBound={() => setView('list')}
onEditConnection={() => setView('connect')}
/> />
) : view === 'detail' && link ? ( ) : view === 'detail' && link ? (
<DetailView <DetailView
@@ -417,6 +419,19 @@ function DetailView({
</button> </button>
</SettingRow> </SettingRow>
{onReconnect && (
<SettingRow label={t('docsync.connection.title')} hint={t('docsync.connection.hint')}>
<button
type="button"
onClick={onReconnect}
className="flex items-center gap-1.5 rounded-full bg-[color:var(--m-ic)] px-3 py-[6px] font-geist text-caption font-bold text-m-ink"
>
<KeyRound size={12} strokeWidth={2.2} />
{t('docsync.connection.edit')}
</button>
</SettingRow>
)}
<button <button
type="button" type="button"
onClick={onUnlink} onClick={onUnlink}
@@ -443,14 +458,18 @@ function ConnectView({
provider, provider,
sync, sync,
onDone, onDone,
onRemoved,
}: { }: {
provider: DocSyncProvider provider: DocSyncProvider
sync: ReturnType<typeof useDocSync> sync: ReturnType<typeof useDocSync>
onDone: () => void onDone: () => void
onRemoved: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const form = useConnectForm(provider, sync.connectionFor(provider.id), sync) const existing = sync.connectionFor(provider.id)
const form = useConnectForm(provider, existing, sync)
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null) const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
const [confirmRemove, setConfirmRemove] = useState(false)
return ( return (
<div className="mt-3 flex flex-col gap-3"> <div className="mt-3 flex flex-col gap-3">
@@ -534,6 +553,32 @@ function ConnectView({
{sync.busy === 'save' ? <Loader2 size={15} className="mx-auto animate-spin" /> : t('common.save')} {sync.busy === 'save' ? <Loader2 size={15} className="mx-auto animate-spin" /> : t('common.save')}
</button> </button>
</div> </div>
{existing && (
<button
type="button"
onClick={() => setConfirmRemove(true)}
disabled={sync.busy === 'connection'}
className="flex h-11 w-full items-center justify-center gap-2 rounded-2xl border border-[color:var(--m-rowbr)] text-body font-bold text-[color:var(--m-st-danger)] disabled:opacity-50"
>
<Trash2 size={15} strokeWidth={2.2} />
{t('docsync.connection.remove')}
</button>
)}
<MConfirmSheet
open={confirmRemove}
onClose={() => setConfirmRemove(false)}
title={t('docsync.connection.remove')}
message={t('docsync.connection.confirmRemove')}
confirmLabel={t('docsync.connection.remove')}
cancelLabel={t('common.cancel')}
danger
onConfirm={() => {
setConfirmRemove(false)
if (existing) void sync.removeConnection(existing.id).then(ok => { if (ok) onRemoved() })
}}
/>
</div> </div>
) )
} }
@@ -544,11 +589,14 @@ function ScopeView({
suggestedName, suggestedName,
sync, sync,
onBound, onBound,
onEditConnection,
}: { }: {
connectionId: number connectionId: number
suggestedName: string suggestedName: string
sync: ReturnType<typeof useDocSync> sync: ReturnType<typeof useDocSync>
onBound: () => void onBound: () => void
/** Back to the stored connection, to change the login or remove it before choosing a folder. */
onEditConnection: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { scopes, error, name, setName, working, bind, createAndBind } = useScopePicker({ const { scopes, error, name, setName, working, bind, createAndBind } = useScopePicker({
@@ -562,6 +610,15 @@ function ScopeView({
<div className="mt-3 flex flex-col gap-3"> <div className="mt-3 flex flex-col gap-3">
<p className="font-geist text-[0.71875rem] leading-snug text-m-muted">{t('docsync.scope.intro')}</p> <p className="font-geist text-[0.71875rem] leading-snug text-m-muted">{t('docsync.scope.intro')}</p>
<button
type="button"
onClick={onEditConnection}
className="flex h-9 items-center justify-center gap-1.5 self-start rounded-full bg-[color:var(--m-ic)] px-3 font-geist text-caption font-bold text-m-ink"
>
<KeyRound size={12} strokeWidth={2.2} />
{t('docsync.connection.edit')}
</button>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
value={name} value={name}
@@ -359,12 +359,22 @@ describe('MAdminPluginsPanel — Discover', () => {
await waitFor(() => expect(toastMessages()).toContain('Installed')); 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 })]); 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' }); const btn = await screen.findByRole('button', { name: /^Install$/ });
expect(btn).toBeDisabled();
expect(btn).toHaveAttribute('title', 'Needs TREK >=4.0.0 — this server runs 3.3.0'); 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 () => { 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(); 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; let body: unknown = null;
mockPanel([], [incompatibleEntry()], { ignoreTrekRange: true }); mockPanel([], [incompatibleEntry()], { ignoreTrekRange: true });
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => { 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 />); render(<MAdminPluginsPanel />);
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ })); 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(); expect(btn).toBeEnabled();
fireEvent.click(btn); 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 }); })); server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }); }));
render(<MAdminPluginsPanel />); render(<MAdminPluginsPanel />);
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ })); 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' }); const sheet = await screen.findByRole('dialog', { name: 'Outside its supported TREK versions' });
fireEvent.click(within(sheet).getByRole('button', { name: 'Cancel' })); fireEvent.click(within(sheet).getByRole('button', { name: 'Cancel' }));
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
// Build server/assets/airports.json from OurAirports (davidmegginson.github.io/ourairports-data). // 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 fs from 'node:fs';
import https from 'node:https'; import https from 'node:https';
import path from 'node:path'; import path from 'node:path';
@@ -91,7 +92,9 @@ for (let i = 1; i < rows.length; i++) {
if (!KEEP.has(r[TYPE])) continue; if (!KEEP.has(r[TYPE])) continue;
const iata = r[IATA]?.trim().toUpperCase(); const iata = r[IATA]?.trim().toUpperCase();
if (!iata || iata.length !== 3) continue; 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 lat = Number(r[LAT]);
const lng = Number(r[LNG]); const lng = Number(r[LNG]);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue; 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-require-imports": 18,
"@typescript-eslint/no-unsafe-function-type": 1, "@typescript-eslint/no-unsafe-function-type": 1,
"@typescript-eslint/no-unused-vars": 36, "@typescript-eslint/no-unused-vars": 36,
"no-await-in-loop": 563, "no-await-in-loop": 562,
"no-console": 235, "no-console": 235,
"no-empty": 6 "no-empty": 6
}, },
+1 -1
View File
@@ -7,7 +7,7 @@
"src/nest/budget/budget.mcp.ts": 36, "src/nest/budget/budget.mcp.ts": 36,
"src/nest/collab/collab.mcp.ts": 20, "src/nest/collab/collab.mcp.ts": 20,
"src/nest/collections/collections.mcp.ts": 14, "src/nest/collections/collections.mcp.ts": 14,
"src/nest/day-notes/day-notes.mcp.ts": 10, "src/nest/day-notes/day-notes.mcp.ts": 6,
"src/nest/days/days.mcp.ts": 2, "src/nest/days/days.mcp.ts": 2,
"src/nest/doc-sync/doc-sync.mcp.ts": 1, "src/nest/doc-sync/doc-sync.mcp.ts": 1,
"src/nest/files/files.mcp.ts": 4, "src/nest/files/files.mcp.ts": 4,
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"scripts/db-call-graph.mjs": 1036, "scripts/db-call-graph.mjs": 1036,
"scripts/generate-entities.ts": 2259, "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/Reservations.repository.ts": 1604,
"src/db/repositories/Trips.repository.ts": 1121, "src/db/repositories/Trips.repository.ts": 1121,
"src/db/repositories/Users.repository.ts": 1445, "src/db/repositories/Users.repository.ts": 1445,
@@ -13,7 +13,7 @@
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160, "src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
"src/nest/journey/journey-domain.service.ts": 1659, "src/nest/journey/journey-domain.service.ts": 1659,
"src/nest/places/places.service.ts": 1592, "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/plugins/runtime/plugin-sdk.ts": 1093,
"src/nest/reservations/reservations.service.ts": 1237, "src/nest/reservations/reservations.service.ts": 1237,
"src/nest/trips/trips.service.ts": 1006, "src/nest/trips/trips.service.ts": 1006,
+4
View File
@@ -35,6 +35,8 @@ export class Plugins {
update_block_version?: string | null; update_block_version?: string | null;
trek_range?: string | null; trek_range?: string | null;
update_hold: number & Opt = 0; update_hold: number & Opt = 0;
trek_range_accepted_version?: string | null;
trek_range_accepted_host?: string | null;
} }
export const PluginsSchema = defineEntity({ export const PluginsSchema = defineEntity({
@@ -72,5 +74,7 @@ export const PluginsSchema = defineEntity({
update_block_version: p.text().nullable(), update_block_version: p.text().nullable(),
trek_range: p.text().nullable(), trek_range: p.text().nullable(),
update_hold: p.integer().default(0), update_hold: p.integer().default(0),
trek_range_accepted_version: p.text().nullable(),
trek_range_accepted_host: p.text().nullable(),
}, },
}); });
+2
View File
@@ -34,4 +34,6 @@ export interface PluginsTable {
update_block_version: InsertOptional<string | null>; update_block_version: InsertOptional<string | null>;
trek_range: InsertOptional<string | null>; trek_range: InsertOptional<string | null>;
update_hold: InsertOptional<number>; 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. * it was.
*/ */
async listDedupInputs(trip_id: string): Promise<{ 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; 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') 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]) .where('p.trip_id = ?', [trip_id])
.execute<{ .execute<Awaited<ReturnType<PlacesRepository['listDedupInputs']>>>('all', false);
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);
} }
/** /**
@@ -26,13 +26,16 @@ export interface PluginIdPermissionRow {
permissions: string | null; 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 { export interface PluginActivationGateRow {
permissions: string; permissions: string;
granted_permissions: string; granted_permissions: string;
dependencies: string | null; dependencies: string | null;
trek_range: string | null; trek_range: string | null;
api_version: number | 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`). */ /** PR18 — `SELECT permissions, config FROM plugins WHERE id = ?` (`spawnActivated`). */
@@ -89,6 +92,8 @@ export interface PluginAdminRow {
dependencies: string | null; dependencies: string | null;
operator_egress: number; operator_egress: number;
trek_range: string | null; trek_range: string | null;
trek_range_accepted_version: string | null;
trek_range_accepted_host: string | null;
author_pubkey: string | null; author_pubkey: string | null;
update_block_code: string | null; update_block_code: string | null;
update_block_detail: string | null; update_block_detail: string | null;
@@ -144,7 +149,18 @@ export class PluginsRepository extends TrekRepository<Plugins> {
async findActivationGate(id: string): Promise<PluginActivationGateRow | null> { async findActivationGate(id: string): Promise<PluginActivationGateRow | null> {
const row = await this.findOne( const row = await this.findOne(
{ id }, { 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 return row
? { ? {
@@ -153,6 +169,9 @@ export class PluginsRepository extends TrekRepository<Plugins> {
dependencies: row.dependencies ?? null, dependencies: row.dependencies ?? null,
trek_range: row.trek_range ?? null, trek_range: row.trek_range ?? null,
api_version: row.api_version ?? 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; : null;
} }
@@ -370,6 +389,8 @@ export class PluginsRepository extends TrekRepository<Plugins> {
'dependencies', 'dependencies',
'operator_egress', 'operator_egress',
'trek_range', 'trek_range',
'trek_range_accepted_version',
'trek_range_accepted_host',
'author_pubkey', 'author_pubkey',
'update_block_code', 'update_block_code',
'update_block_detail', 'update_block_detail',
@@ -396,6 +417,8 @@ export class PluginsRepository extends TrekRepository<Plugins> {
dependencies: r.dependencies ?? null, dependencies: r.dependencies ?? null,
operator_egress: r.operator_egress, operator_egress: r.operator_egress,
trek_range: r.trek_range ?? null, 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, author_pubkey: r.author_pubkey ?? null,
update_block_code: r.update_block_code ?? null, update_block_code: r.update_block_code ?? null,
update_block_detail: r.update_block_detail ?? 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 }); 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 = ?, * RG8 (`commitUpload`, sideload step 2) — `` UPDATE plugins SET source_repo = ?,
* source_commit = NULL, sha256 = NULL, reviewed_at = NULL, author_pubkey = NULL, * source_commit = NULL, sha256 = NULL, reviewed_at = NULL, author_pubkey = NULL,
@@ -91,7 +91,17 @@ const PROVIDER_MESSAGES: Partial<Record<AssistantErrorCode, string>> = {
provider_unavailable: 'The provider is not responding', 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 { 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'); return aiRefusal(err.code, PROVIDER_MESSAGES[err.code] ?? 'The provider refused the request');
} }
@@ -238,7 +238,15 @@ export class ModelCatalogService {
const { models } = await this.llmLocal.listModels(baseUrl ?? undefined, apiKey ?? undefined); const { models } = await this.llmLocal.listModels(baseUrl ?? undefined, apiKey ?? undefined);
return models.map((m) => ({ id: m.name, label: m.name, supportsTools: true })); return models.map((m) => ({ id: m.name, label: m.name, supportsTools: true }));
} catch (err) { } 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; throw err;
} }
} }
@@ -59,11 +59,21 @@ export async function refusalOf(kind: AiConnectionKind, res: Response): Promise<
// refusal the provider's own code already explains (a plan that does not cover it) keeps that code. // refusal the provider's own code already explains (a plan that does not cover it) keeps that code.
const image = namesImageInput(res.status, body) && providerFailure(kind, res.status, named).code === 'generic'; const image = namesImageInput(res.status, body) && providerFailure(kind, res.status, named).code === 'generic';
const code = image ? IMAGE_INPUT_REFUSED : named; const code = image ? IMAGE_INPUT_REFUSED : named;
// Status and code only: an error body can quote the request, and the request holds the trip. // Status, code and the refused field's path only: an error message can quote the request, and the
logWarn(`[ai] ${kind} refused the request: HTTP ${res.status}${code ? ` (${code})` : ''}`); // request holds the trip. The path alone says which part of a replayed conversation the provider refused.
const param = refusedParamOf(body);
logWarn(
`[ai] ${kind} refused the request: HTTP ${res.status}${code ? ` (${code})` : ''}${param ? ` at ${param}` : ''}`,
);
return providerFailure(kind, res.status, code); return providerFailure(kind, res.status, code);
} }
/** The field an error body names as refused (OpenAI's `param`, such as `input[12]`), when it is a bare path. */
function refusedParamOf(body: unknown): string | undefined {
const param = asRecord(asRecord(body).error).param;
return typeof param === 'string' && /^[\w.[\]-]{1,80}$/.test(param) ? param : undefined;
}
/** One SSE data payload as a JSON object; null for anything else (a `[DONE]`, a keepalive). */ /** One SSE data payload as a JSON object; null for anything else (a `[DONE]`, a keepalive). */
export function parseEventData(data: string): Record<string, unknown> | null { export function parseEventData(data: string): Record<string, unknown> | null {
try { try {
+22 -38
View File
@@ -9,6 +9,7 @@ import { fitToSize } from './assistant-output';
import { import {
ADD_FOUND_PLACE_TOOL, ADD_FOUND_PLACE_TOOL,
APPROVAL_TTL_MS, APPROVAL_TTL_MS,
ASSISTANT_MODEL_CALLED_SERVER_TOOLS,
ASSISTANT_SERVER_TOOLS, ASSISTANT_SERVER_TOOLS,
IMPORT_BOOKING_TOOL, IMPORT_BOOKING_TOOL,
IMPORT_TOUR_FILE_TOOL, IMPORT_TOUR_FILE_TOOL,
@@ -190,13 +191,6 @@ function earlierCut(text: string, earlier: boolean): string {
: text; : text;
} }
/**
* The model's own calls on attached files: told after the step that made
* them, so the history ends on the user's side when the user's decision on
* them goes on.
*/
const ATTACHED_FILE_TOOLS: ReadonlySet<string> = new Set([READ_ATTACHED_FILE_TOOL, KEEP_ATTACHED_FILE_TOOL]);
/** What became of keeping an attached file in Files, as its stored part says. */ /** What became of keeping an attached file in Files, as its stored part says. */
function keptFileLine(part: ToolCallPart): string { function keptFileLine(part: ToolCallPart): string {
const file = quoted(part.args.name); const file = quoted(part.args.name);
@@ -234,30 +228,31 @@ function attachedReadLine(
} }
/** /**
* What the model is told of its reads of attached files in one step: the * The answer a replayed call gets: what became of an attached file (the text
* text of each file the user let it read, the first time the history holds * of a file the user let it read, the first time the history holds it, `told`
* it (`told` keeps track), and what became of a read the user did not allow. * keeping track; `texts` are the shared files' texts by attachment id), "not
* `texts` are the shared files' texts, by attachment id; one no longer there * applied" for a held change that never ran, the stored result otherwise.
* was swept with its file.
*/ */
function attachedReadNote( function replayedOutput(
calls: readonly ToolCallPart[], part: ToolCallPart,
texts: ReadonlyMap<string, string>, texts: ReadonlyMap<string, string>,
told: Set<string>, told: Set<string>,
earlier: boolean, earlier: boolean,
): string { ): string {
return calls if (part.toolName === KEEP_ATTACHED_FILE_TOOL) return keptFileLine(part);
.map((part) => if (part.toolName === READ_ATTACHED_FILE_TOOL) return attachedReadLine(part, texts, told, earlier);
part.toolName === KEEP_ATTACHED_FILE_TOOL ? keptFileLine(part) : attachedReadLine(part, texts, told, earlier), if (part.result === undefined) return NOT_APPLIED;
) return outputText(earlier ? fitToSize(part.result, EARLIER_TOOL_OUTPUT_CHARS) : part.result);
.join('\n\n');
} }
/** /**
* The thread as the provider reads it, oldest first, from its first user * The thread as the provider reads it, oldest first, from its first user
* message on. A step's tool calls are followed by their answers; a held * message on. A step's tool calls are followed by their answers; a held
* change that was never applied answers that it was not, since every call * change that was never applied answers that it was not, since every call
* must be answered. `providerIds` maps a held call's own id to the id the * must be answered. That includes the server's tools the model called itself
* (a trip note, an attached file): the providers that replay their own record
* of a step refuse it with a call left unanswered, and every later message of
* the thread with it. `providerIds` maps a held call's own id to the id the
* provider gave it. * provider gave it.
*/ */
export function toChatHistory( export function toChatHistory(
@@ -282,42 +277,31 @@ export function toChatHistory(
(part): part is Extract<AssistantMessagePart, { type: 'tool-call' }> => (part): part is Extract<AssistantMessagePart, { type: 'tool-call' }> =>
part.type === 'tool-call' && part.toolName !== ASSISTANT_WEB_SEARCH_STEP, part.type === 'tool-call' && part.toolName !== ASSISTANT_WEB_SEARCH_STEP,
); );
const reads = allCalls.filter((part) => ATTACHED_FILE_TOOLS.has(part.toolName)); const serverStep = (part: ToolCallPart) =>
const serverCalls = allCalls.filter( ASSISTANT_SERVER_TOOLS.has(part.toolName) && !ASSISTANT_MODEL_CALLED_SERVER_TOOLS.has(part.toolName);
(part) => ASSISTANT_SERVER_TOOLS.has(part.toolName) && !ATTACHED_FILE_TOOLS.has(part.toolName),
);
// Told as the user's side of the conversation: the history then ends on it // Told as the user's side of the conversation: the history then ends on it
// when a decision on these bookings starts the next answer. // when a decision on these bookings starts the next answer.
if (serverCalls.length > 0) history.push({ role: 'user', text: serverStepsNote(serverCalls) }); const note = serverStepsNote(allCalls.filter(serverStep));
const calls = allCalls.filter((part) => !ASSISTANT_SERVER_TOOLS.has(part.toolName)); if (note) history.push({ role: 'user', text: note });
const calls = allCalls.filter((part) => !serverStep(part));
const toolCalls: AiToolCall[] = calls.map((part) => ({ const toolCalls: AiToolCall[] = calls.map((part) => ({
id: providerIds.get(part.toolCallId) ?? part.toolCallId, id: providerIds.get(part.toolCallId) ?? part.toolCallId,
name: part.toolName, name: part.toolName,
args: part.args, args: part.args,
})); }));
const text = textOf(parts); const text = textOf(parts);
// Told after the step that read the files, so the history ends on the user's side when a consent goes on. if (!text && toolCalls.length === 0) return;
const readNote = reads.length > 0 ? attachedReadNote(reads, attachedTexts, told, earlier) : '';
if (!text && toolCalls.length === 0) {
if (readNote) history.push({ role: 'user', text: readNote });
return;
}
const providerState = decodeJson(ASSISTANT_PROVIDER_STATE, row.provider_state, `message ${row.id}`); const providerState = decodeJson(ASSISTANT_PROVIDER_STATE, row.provider_state, `message ${row.id}`);
history.push({ role: 'assistant', text, toolCalls, providerState }); history.push({ role: 'assistant', text, toolCalls, providerState });
calls.forEach((part, index) => { calls.forEach((part, index) => {
const output =
part.result === undefined
? NOT_APPLIED
: outputText(earlier ? fitToSize(part.result, EARLIER_TOOL_OUTPUT_CHARS) : part.result);
history.push({ history.push({
role: 'tool', role: 'tool',
toolCallId: toolCalls[index].id, toolCallId: toolCalls[index].id,
name: part.toolName, name: part.toolName,
output, output: replayedOutput(part, attachedTexts, told, earlier),
...(part.isError ? { isError: true } : {}), ...(part.isError ? { isError: true } : {}),
}); });
}); });
if (readNote) history.push({ role: 'user', text: readNote });
}); });
return history; return history;
} }
@@ -255,6 +255,18 @@ export const ASSISTANT_SERVER_TOOLS: ReadonlySet<string> = new Set([
...Object.keys(ASSISTANT_SERVER_WRITE_TOOLS), ...Object.keys(ASSISTANT_SERVER_WRITE_TOOLS),
]); ]);
/**
* The server's tools the model calls itself. TREK answers them, but they are
* calls in the provider's own record of the step, so a replayed step has to
* answer them like any other; the rest of the server's tools are steps the
* server took, which the provider never saw.
*/
export const ASSISTANT_MODEL_CALLED_SERVER_TOOLS: ReadonlySet<string> = new Set([
REMEMBER_TOOL,
READ_ATTACHED_FILE_TOOL,
KEEP_ATTACHED_FILE_TOOL,
]);
/** The largest file the assistant reads. */ /** The largest file the assistant reads. */
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
@@ -195,7 +195,7 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
const dayId = id(args, 'dayId'); const dayId = id(args, 'dayId');
const rows = new Rows() const rows = new Rows()
.add('day', dayLabel(ctx, dayId)) .add('day', dayLabel(ctx, dayId))
.add('time', text(args.time)) .add('content', text(args.time))
.add('icon', text(args.icon)) .add('icon', text(args.icon))
.add('color', text(args.color)); .add('color', text(args.color));
return { subject: text(args.text), rows, dayId, placeIds: [] }; return { subject: text(args.text), rows, dayId, placeIds: [] };
@@ -203,8 +203,8 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
update_day_note: (ctx, args) => { update_day_note: (ctx, args) => {
const note = ctx.card.notes.get(id(args, 'noteId') ?? 0); const note = ctx.card.notes.get(id(args, 'noteId') ?? 0);
const rows = new Rows(); const rows = new Rows();
if (has(args, 'text')) rows.change('text', note?.text ?? null, text(args.text)); if (has(args, 'text')) rows.change('title', note?.text ?? null, text(args.text));
if (has(args, 'time')) rows.change('time', note?.time ?? null, text(args.time)); if (has(args, 'time')) rows.change('content', note?.time ?? null, text(args.time));
if (has(args, 'icon')) rows.change('icon', note?.icon ?? null, text(args.icon)); if (has(args, 'icon')) rows.change('icon', note?.icon ?? null, text(args.icon));
if (has(args, 'color')) rows.change('color', note?.color ?? null, text(args.color)); if (has(args, 'color')) rows.change('color', note?.color ?? null, text(args.color));
return { subject: note?.text ?? text(args.text), rows, dayId: id(args, 'dayId'), placeIds: [] }; return { subject: note?.text ?? text(args.text), rows, dayId: id(args, 'dayId'), placeIds: [] };
+27 -13
View File
@@ -11,7 +11,13 @@ import {
} from '../../nest-mcp'; } from '../../nest-mcp';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { DayNotesService } from './day-notes.service'; import { DayNotesService } from './day-notes.service';
import { idSchema, NOTE_COLORS, type NoteColor } from '@trek/shared'; import {
dayNoteCreateRequestSchema,
dayNoteUpdateRequestSchema,
idSchema,
NOTE_COLORS,
type NoteColor,
} from '@trek/shared';
import { z } from 'zod'; import { z } from 'zod';
@@ -23,6 +29,18 @@ import { z } from 'zod';
*/ */
const noteColorSchema = z.enum(NOTE_COLORS); const noteColorSchema = z.enum(NOTE_COLORS);
/**
* The note's two text fields carry their names from the time a note was a line
* with a clock time: `text` is the heading and `time` the Markdown body under it.
* The REST contract holds the bounds, so a note written in the planner can always
* be edited here; the descriptions are what tells a model which field is which.
*/
const NOTE_TITLE = 'The note title, shown as its heading in the day plan. Keep it short';
const NOTE_BODY =
'The note body shown under the title: details, links, reminders, Markdown allowed. ' +
'Despite the field name this is the content, not a clock time';
const noteTitleSchema = dayNoteCreateRequestSchema.shape.text.describe(NOTE_TITLE);
function parseId(value: string | string[]): number | null { function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value); const n = Number(Array.isArray(value) ? value[0] : value);
return Number.isInteger(n) && n > 0 ? n : null; return Number.isInteger(n) && n > 0 ? n : null;
@@ -52,12 +70,12 @@ export class DayNotesMcp {
@Tool({ @Tool({
name: 'create_day_note', name: 'create_day_note',
description: 'Add a note to a specific day in a trip.', description: 'Add a note to a specific day in a trip: a title in `text` and its body in `time`.',
inputSchema: { inputSchema: {
tripId: idSchema, tripId: idSchema,
dayId: idSchema, dayId: idSchema,
text: z.string().min(1).max(500), text: noteTitleSchema,
time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'), time: dayNoteCreateRequestSchema.shape.time.describe(NOTE_BODY),
icon: z.string().max(64).optional().describe('Emoji icon for the note'), icon: z.string().max(64).optional().describe('Emoji icon for the note'),
color: noteColorSchema color: noteColorSchema
.nullable() .nullable()
@@ -86,7 +104,7 @@ export class DayNotesMcp {
tripId: number; tripId: number;
dayId: number; dayId: number;
text: string; text: string;
time?: string; time?: string | null;
icon?: string; icon?: string;
color?: NoteColor | null; color?: NoteColor | null;
sort_order?: number; sort_order?: number;
@@ -104,18 +122,14 @@ export class DayNotesMcp {
@Tool({ @Tool({
name: 'update_day_note', name: 'update_day_note',
description: 'Edit an existing note on a specific day.', description: 'Edit an existing note on a specific day: its title is `text`, its body `time`.',
inputSchema: { inputSchema: {
tripId: idSchema, tripId: idSchema,
dayId: idSchema, dayId: idSchema,
noteId: idSchema, noteId: idSchema,
text: z.string().min(1).max(500).optional(), // The create bound, so an update cannot blank the title the planner requires.
time: z text: noteTitleSchema.optional(),
.string() time: dayNoteUpdateRequestSchema.shape.time.describe(`${NOTE_BODY}, or null to clear it`),
.max(250)
.nullable()
.optional()
.describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
icon: z.string().max(64).optional().describe('Emoji icon for the note'), icon: z.string().max(64).optional().describe('Emoji icon for the note'),
color: noteColorSchema color: noteColorSchema
.nullable() .nullable()
+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. */ /** 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() })) }); 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. */ /** The Bearer header the extraction sends, when a key is configured. */
function authHeader(apiKey: string | undefined): Record<string, string> { function authHeader(apiKey: string | undefined): Record<string, string> {
return apiKey ? { authorization: `Bearer ${apiKey}` } : {}; return apiKey ? { authorization: `Bearer ${apiKey}` } : {};
@@ -43,11 +49,23 @@ function upstreamRefusal(status: number, apiKey: string | undefined, fallback: s
apiKey apiKey
? `Local LLM server rejected the API key (${status})` ? `Local LLM server rejected the API key (${status})`
: `Local LLM server requires an API key (${status}). Save the key for this address first.`, : `Local LLM server requires an API key (${status}). Save the key for this address first.`,
{ code: 'llm_key_refused' },
); );
} }
return new DomainError(502, fallback); 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). * Admin helpers for managing a local OpenAI-compatible LLM server (Ollama).
* Talks to Ollama's *management* API (`/api/tags`, `/api/pull`), which lives at * 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:') { if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new HttpException({ error: 'Base URL must be http(s)' }, 400); throw new HttpException({ error: 'Base URL must be http(s)' }, 400);
} }
// Trailing slashes are scanned off instead of `/\/+$/`-replaced: that pattern return trimTrailingSlashes(raw).replace(/\/v1$/, '');
// 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$/, '');
} }
/** /**
@@ -94,39 +108,69 @@ export class LlmLocalService {
} }
/** /**
* List models already pulled on the local server. A server without Ollama's * List the models the server offers. Ollama's `/api/tags` is asked first, then
* `/api/tags` (LM Studio, vLLM, llama.cpp) answers 404 there, so its * the OpenAI-compatible list: at the address as entered (the one the chat
* OpenAI-compatible `/v1/models` is asked instead. * 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( async listModels(
baseUrl: string | undefined, baseUrl: string | undefined,
apiKey?: string, apiKey?: string,
): Promise<{ models: { name: string; size: number }[] }> { ): Promise<{ models: { name: string; size: number }[] }> {
const root = this.ollamaRoot(baseUrl); const root = this.ollamaRoot(baseUrl);
let res = await this.getModelList(`${root}/api/tags`, root, apiKey); const asEntered = trimTrailingSlashes((baseUrl ?? '').trim()) || root;
let openAi = false; const lists: ModelListSource[] = [
if (res.status === 404) { { url: `${root}/api/tags`, read: (body) => tagsResponseSchema.safeParse(body).data?.models },
const fallback = await this.getModelList(`${root}/v1/models`, root, apiKey); ...[...new Set([`${asEntered}/models`, `${root}/v1/models`])].map((url) => ({
if (fallback.ok) { url,
discardBody(res); read: (body: unknown) => openAiModelsSchema.safeParse(body).data?.data.map((m) => ({ name: m.id, size: 0 })),
res = fallback; })),
openAi = true; ];
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 { } else {
// Neither list exists: report the first answer, as before. discardBody(res);
discardBody(fallback);
} }
} }
if (!res.ok) { return this.firstModelList(lists, index + 1, root, apiKey, seen);
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 };
} }
private async getModelList(url: string, root: string, apiKey: string | undefined): Promise<Response> { 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? * 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}` * 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 { readEnv } from '../../../app-config';
import { safeFetchLlm } from '../../../utils/ssrfGuard'; import { safeFetchLlm } from '../../../utils/ssrfGuard';
import { OpenAiCompatibleClient } from '../clients/openai-compatible.client';
import { parseLenientJson } from '../lenient-json'; import { parseLenientJson } from '../lenient-json';
import type { LlmExtractionFile } from '../llm-provider.interface';
export interface EnforcedExtractInput { export interface EnforcedExtractInput {
/** Ollama base URL — accepts the addon's `…/v1` form; the `/v1` suffix is stripped. */ /** 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$/, ''); 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`. * 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 * 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) { if (!res.ok) {
const detail = await res.text().catch(() => ''); 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)}`); 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); const parsed = parseLenientJson(data.message?.content);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null; 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 { export interface DedupSet {
names: Set<string>; 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 }>; 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. */ /** Provider ids (google_place_id, google_ftid, osm_id, amap_poi_id) of the places already in the trip. */
externalIds: Set<string>; 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. * 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 * JavaScript's is not, and only unnamed rows contribute coordinates to a dedup
* set. `findDuplicatePlace` spells out where the two still answer differently. * 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)) { for (const strategy of placeMatchStrategies(candidate)) {
if (strategy.by === 'externalId') { if (strategy.by === 'externalId') {
if (dedup.externalIds.has(strategy.id)) return true; if (dedup.externalIds.has(strategy.id)) return true;
} else if (strategy.by === 'name') { } 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 ( } else if (
dedup.coords.some( dedup.coords.some(
(c) => (c) =>
@@ -130,29 +137,34 @@ export function isPlaceDuplicate(candidate: PlaceMatchCandidate, dedup: DedupSet
*/ */
export function dedupSetOf( export function dedupSetOf(
rows: ReadonlyArray< 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 { ): DedupSet {
const names = new Set<string>(); const names = new Set<string>();
const trackNames = new Set<string>();
const coords: Array<{ lat: number; lng: number }> = []; const coords: Array<{ lat: number; lng: number }> = [];
const externalIds = new Set<string>(); const externalIds = new Set<string>();
for (const row of rows) { for (const row of rows) {
for (const id of externalIdsOf(row)) externalIds.add(id); for (const id of externalIdsOf(row)) externalIds.add(id);
if (row.name) { if (row.name) {
names.add(row.name.trim().toLowerCase()); names.add(row.name.trim().toLowerCase());
if (row.route_geometry) trackNames.add(row.name.trim().toLowerCase());
} else if (row.lat != null && row.lng != null) { } else if (row.lat != null && row.lng != null) {
coords.push({ lat: row.lat, lng: row.lng }); 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. */ /** 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); for (const id of externalIdsOf(place)) dedup.externalIds.add(id);
const normalizedName = normalizePlaceName(place.name); const normalizedName = normalizePlaceName(place.name);
if (normalizedName) { if (normalizedName) {
dedup.names.add(normalizedName); dedup.names.add(normalizedName);
if (place.track) (dedup.trackNames ??= new Set()).add(normalizedName);
} else if (place.lat != null && place.lng != null) { } else if (place.lat != null && place.lng != null) {
dedup.coords.push({ lat: place.lat, lng: place.lng }); 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[]> { async gpxDuplicates(tripId: string, rows: PreparedGpxPlace[]): Promise<boolean[]> {
const dedup = await this.buildDedupSet(tripId); const dedup = await this.buildDedupSet(tripId);
return rows.map((wp) => { 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; if (isPlaceDuplicate(candidate, dedup)) return true;
trackInsertedInDedupSet(candidate, dedup); trackInsertedInDedupSet(candidate, dedup);
return false; return false;
@@ -925,7 +925,7 @@ export class PlacesService {
// `findWithTagsAndRatings` INSIDE the caller's transaction, so the read // `findWithTagsAndRatings` INSIDE the caller's transaction, so the read
// sees the still-uncommitted row. // sees the still-uncommitted row.
for (const wp of waypoints) { 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++; skipped++;
continue; continue;
} }
@@ -960,7 +960,7 @@ export class PlacesService {
}); });
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!; const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
created.push(place); 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 }; return { places: created, count: created.length, skipped };
@@ -1033,7 +1033,7 @@ export class PlacesService {
const fallbackName = `Placemark ${fallbackIndex}`; const fallbackName = `Placemark ${fallbackIndex}`;
const name = parsedPlacemark.name || fallbackName; 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; summary.skippedCount += 1;
dupCount++; dupCount++;
fallbackIndex += 1; fallbackIndex += 1;
@@ -1079,7 +1079,7 @@ export class PlacesService {
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!; const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
created.push(place); 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; summary.createdCount += 1;
fallbackIndex += 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. */ /** The plugin's declared range, or null when it declared none at all. */
trekRange: string | null; trekRange: string | null;
hostVersion: string; 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 * Whether an admin's consent still covers the plugin as it is installed now: the
* through: the marker that makes an install/activate response say "this worked, but * same plugin version, on the same TREK major. An update of the plugin, or a TREK
* only because you told TREK to ignore the version check". Null for a normal install * major past the one the admin saw it run on, asks again.
* (the range fits) and null when the bypass is off (the gate refuses instead).
*/ */
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 (hostSatisfies(range)) return null;
if (!trekRangeBypassed()) return null; const everyPlugin = trekRangeBypassed();
return { trekRange: range ?? null, hostVersion: hostVersion() }; 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 { export function warnRangeBypass(id: string, b: TrekRangeBypass): void {
const declared = b.trekRange ? `declares TREK ${b.trekRange}` : 'declares no TREK version range'; 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( 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 { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link';
import { pluginCodeDir, pluginDataDir } from './paths'; import { pluginCodeDir, pluginDataDir } from './paths';
import { assertHostCompatible, PluginRegistryService, RegistryError } from './registry/registry.service'; 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 type { TrekRangeBypass } from './install/host-compat';
import { keyFingerprint } from './signature-status'; import { keyFingerprint } from './signature-status';
import { AuditService } from '../audit/audit.service'; 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 * freshly-installed plugin (no prior grant) is itself the consent for its
* declared set; widening an already-consented set requires `consentWiden` (the * declared set; widening an already-consented set requires `consentWiden` (the
* update consent dialog), and otherwise throws PluginConsentRequired. * 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(); const installed = await this.installedDepRows();
// Deps-first order over the installed graph (throws DependencyCycleError on a // 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 // 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 // blocked dependency never leaves the chain half-activated. Only the target
// may consent-widen; dependencies are auto-enabled at their existing grant. // may consent-widen; dependencies are auto-enabled at their existing grant.
const toCheck = rootInstalled ? order : [id]; 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); for (const nodeId of toCheck) await this.assertActivatable(nodeId, installed, nodeId === id ? consentWiden : false);
// Enable dependencies first (skip ones already enabled), then the target. // 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. */ /** All plugin rows projected to what the dependency helpers reason over. */
private async installedDepRows(): Promise<Map<string, PluginDepRow>> { private async installedDepRows(): Promise<Map<string, PluginDepRow>> {
const rows: PluginDepRow[] = await this.plugins.listDepRows(); 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 // 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 // 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. // that was legitimately installed no longer supports the host it's sitting on.
const bypass = bypassedRange(row.trek_range); const refusal = trekRangeRefusal(id, row);
if (bypass) { if (refusal) throw new PluginDependencyError(refusal.message, refusal.code, refusal.detail);
// 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() },
);
}
// Same reasoning as the TREK-version gate above: a plugin whose manifest apiVersion // 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 // outpaces this TREK's plugin-API surface can never run correctly, so it must be
// refused before any consent dialog is offered. // 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. // compatible version and reporting addons the admin still has to enable.
// Dependency resolution pins versions internally but never DELIBERATELY, so // Dependency resolution pins versions internally but never DELIBERATELY, so
// only the plain path below recomputes the update hold. // only the plain path below recomputes the update hold.
if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint); const acceptTrekRange = body.acceptTrekRange === true;
const res = await this.registry.install(body.id, { version: body.version, constraint: body.constraint }); 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); await this.registry.recomputeUpdateHold(res.id, res.version, !!body.version);
return res; return res;
} catch (e) { } catch (e) {
@@ -272,7 +279,7 @@ export class PluginsController {
async activate(@Param('id') id: string, @Body() body: PluginActivateDto) { async activate(@Param('id') id: string, @Body() body: PluginActivateDto) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503); if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
try { try {
await this.runtime.activate(id, !!body?.consent); await this.runtime.activate(id, !!body?.consent, body?.acceptTrekRange === true);
} catch (e) { } catch (e) {
// Re-enabling a plugin whose update widened its permissions must NOT grant // 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. // 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 { devLinkEnabled } from './dev-link';
import { readAudit } from './host/plugin-audit'; import { readAudit } from './host/plugin-audit';
import { pluginBudgetUsage } from './host/plugin-host-state'; 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 { TrekRangeBypass } from './install/host-compat';
import type { PluginDependency } from './install/manifest'; import type { PluginDependency } from './install/manifest';
import { pluginsEnabled } from './kill-switch'; import { pluginsEnabled } from './kill-switch';
@@ -71,6 +71,8 @@ interface PluginRawRow {
capabilities: string; capabilities: string;
dependencies: string | null; dependencies: string | null;
trek_range: string | null; trek_range: string | null;
trek_range_accepted_version: string | null;
trek_range_accepted_host: string | null;
author_pubkey: string | null; author_pubkey: string | null;
update_block_code: string | null; update_block_code: string | null;
update_block_detail: 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. */ /** The running TREK, so the UI can say "needs X, you have Y" without doing semver. */
hostVersion: string; hostVersion: string;
/** /**
* Non-null when the plugin is outside its declared range (or declared none) and only * Non-null when the plugin is outside its declared range (or declared none) and only an
* TREK_PLUGINS_IGNORE_TREK_RANGE lets it activate. The card shows it as a warning * admin's "run it anyway" for this plugin, or TREK_PLUGINS_IGNORE_TREK_RANGE, lets it
* rather than a blocker: the admin chose this, but must keep seeing what they chose. * 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; trekRangeBypassed: TrekRangeBypass | null;
/** The concrete blockers, so the UI can render chips + the resolve dialog. */ /** The concrete blockers, so the UI can render chips + the resolve dialog. */
@@ -200,7 +203,13 @@ export class PluginsService {
const state = resolveDependencyState(deps, installed); const state = resolveDependencyState(deps, installed);
// Mirrors the order of assertActivatable's gate, so the card explains the same // 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. // 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 = const dependencyStatus: PluginDependencyStatus =
!hostSatisfies(r.trek_range) && !trekBypass !hostSatisfies(r.trek_range) && !trekBypass
? 'hostIncompatible' ? 'hostIncompatible'
@@ -213,6 +222,8 @@ export class PluginsService {
dependencies: _raw, dependencies: _raw,
operator_egress: _oe, operator_egress: _oe,
trek_range, trek_range,
trek_range_accepted_version: _acceptedVersion,
trek_range_accepted_host: _acceptedHost,
author_pubkey, author_pubkey,
update_block_code, update_block_code,
update_block_detail, update_block_detail,
@@ -392,11 +392,11 @@ export class PluginRegistryService {
* version (`minTrekVersion`/`maxTrekVersion`). Throws if the plugin isn't in the * version (`minTrekVersion`/`maxTrekVersion`). Throws if the plugin isn't in the
* registry or nothing qualifies. Backs "download the latest compatible version". * 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 reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id); const entry = reg.plugins.find((p) => p.id === id);
if (!entry) throw new RegistryError(`plugin ${id} not in registry`); 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) { if (!v) {
throw new RegistryError( throw new RegistryError(
constraint constraint
@@ -427,6 +427,15 @@ export class PluginRegistryService {
return [...candidates].sort((a, b) => semver.rcompare(a.version, b.version))[0]; 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 * 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" * 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 — * 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. * 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) { if (opts?.version) {
const ver = entry.versions.find((v) => v.version === 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 (!ver) throw new RegistryError(`version ${opts.version} not found for ${entry.id}`);
if (!installable(ver, normalizedHost())) { if (!accept && !installable(ver, normalizedHost())) {
throw new RegistryError( throw new RegistryError(
`${entry.id} ${ver.version} requires TREK ${trekRequirement(ver)} — this is TREK ${hostVersion()}`, `${entry.id} ${ver.version} requires TREK ${trekRequirement(ver)} — this is TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE', 'TREK_VERSION_INCOMPATIBLE',
@@ -446,7 +459,8 @@ export class PluginRegistryService {
} }
return ver; 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; if (ver) return ver;
// Nothing compatible. Say WHY against the newest version the admin was actually // 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. // 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( async install(
id: string, 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 }> { ): Promise<{ id: string; version: string; trekRangeBypassed: TrekRangeBypass | null }> {
const reg = await this.fetchRegistry(); const reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id); 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 // 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 // plugin that declares "<4.0.0" passes the pre-download filter on TREK 4 and is
// caught only here. // 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'); if (scanForNativeBinaries(pluginRoot).length) throw new RegistryError('artifact contains native binaries');
// 6. atomic move into place // 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 // The plugin is now on new code that passed every check — whatever refusal was
// recorded before no longer describes reality. // recorded before no longer describes reality.
await clearUpdateBlock(this.plugins, id); 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 }; return { id, version: ver.version, trekRangeBypassed };
} finally { } finally {
@@ -560,6 +577,7 @@ export class PluginRegistryService {
async installWithDependencies( async installWithDependencies(
id: string, id: string,
constraint?: string, constraint?: string,
acceptTrekRange = false,
): Promise<{ installed: string[]; requiredAddons: string[]; trekRangeBypassed: TrekRangeBypass | null }> { ): Promise<{ installed: string[]; requiredAddons: string[]; trekRangeBypassed: TrekRangeBypass | null }> {
let trekRangeBypassed: TrekRangeBypass | null = null; let trekRangeBypassed: TrekRangeBypass | null = null;
const installedNow = new Set(await this.plugins.listAllIds()); 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> => { const visit = async (pid: string, range: string | undefined, stack: string[]): Promise<void> => {
if (done.has(pid)) return; if (done.has(pid)) return;
if (stack.includes(pid)) throw new RegistryError(`plugin dependency cycle: ${[...stack, pid].join(' -> ')}`); 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)) { if (!installedNow.has(pid)) {
// Install the version we just RESOLVED. Passing the range back (or nothing, for // 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] — // 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. // 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 // The admin asked for the root; that is the bypass they are warned about (a
// dependency's own bypass still lands in the log). // dependency's own bypass still lands in the log).
if (pid === id) trekRangeBypassed = res.trekRangeBypassed; 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, * 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. * 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 * Under TREK_PLUGINS_IGNORE_TREK_RANGE, or when the admin `accepted` this one plugin
* returned marker is what the front door hands back so the admin is told too. Null * outside its range, the refusal becomes a log warning, and the returned marker is what
* means the range fits and nothing was bypassed. * 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; if (hostSatisfies(range)) return null;
const bypass = bypassedRange(range); const bypass = bypassedRange(range, accepted);
if (bypass) { if (bypass) {
warnRangeBypass(id, bypass); warnRangeBypass(id, bypass);
return 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. */ /** 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; const times = row?.trackTimes;
return times return times
? { started: dbNow(new Date(times.startedAt)), ended: dbNow(new Date(times.endedAt)) } ? { 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, (row, at) => !taken.has(at) && row.name === place.name && row.lat === place.lat && row.lng === place.lng,
); );
if (index >= 0) taken.add(index); if (index >= 0) taken.add(index);
byPlace.set(place.id, storedTimes(rows[index])); byPlace.set(place.id, recordedTimesOf(rows[index]));
} }
return byPlace; return byPlace;
} }
@@ -3,6 +3,7 @@ import type { PreparedGpxPlace } from '../place-import/place-import.types';
import { PlacesService } from '../places/places.service'; import { PlacesService } from '../places/places.service';
import { readTourFile } from './tour-file-import.helpers'; import { readTourFile } from './tour-file-import.helpers';
import { gpxOfLine, splitGpxTrackPieces } from './tour-file-split.helpers'; import { gpxOfLine, splitGpxTrackPieces } from './tour-file-split.helpers';
import { tracksToAdopt } from './tour-track-adoption';
import { computeTourMetrics, parseRouteGeometry } from './tours.helpers'; import { computeTourMetrics, parseRouteGeometry } from './tours.helpers';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { TOUR_GPX_TEXT_MAX, type TourFileLeftOut } from '@trek/shared'; import { TOUR_GPX_TEXT_MAX, type TourFileLeftOut } from '@trek/shared';
@@ -15,7 +16,10 @@ export interface TourFileTrack {
point_count: number; point_count: number;
distance_km: number; distance_km: number;
has_heights: boolean; has_heights: boolean;
/** A track already in the trip under the same name and start, which the import would skip. */ /**
* A track already in the trip under the same name and start, which the import would skip. A track
* from before Tours under the same name is none: the import takes it over as a tour (#2810).
*/
duplicate: boolean; duplicate: boolean;
} }
@@ -55,7 +59,9 @@ export class TourFileReadService {
async read(tripId: number, buffer: Buffer, fileName: string): Promise<TourFileTracks> { async read(tripId: number, buffer: Buffer, fileName: string): Promise<TourFileTracks> {
const { rows, leftOut } = await readTourFile(this.placeImport, buffer, fileName); const { rows, leftOut } = await readTourFile(this.placeImport, buffer, fileName);
if (rows.length === 0) return { tracks: [], whole: false }; if (rows.length === 0) return { tracks: [], whole: false };
const duplicates = await this.places.gpxDuplicates(String(tripId), rows); const found = await this.places.gpxDuplicates(String(tripId), rows);
const adopt = tracksToAdopt(rows, await this.places.list(String(tripId), {}));
const duplicates = found.map((duplicate, index) => duplicate && !adopt.has(index));
const extra = leftOut ? { left_out: leftOut } : {}; const extra = leftOut ? { left_out: leftOut } : {};
if (!isGpxName(fileName)) { if (!isGpxName(fileName)) {
const tracks = rows.map((row, index) => const tracks = rows.map((row, index) =>
@@ -0,0 +1,42 @@
import { normalizePlaceName } from '@trek/shared';
/** A place of the trip as the places list reads it: enough to tell a track that is no tour yet. */
export interface TripPlaceForAdoption {
id: number;
name: string;
route_geometry?: string | null;
tour_place_id?: number | null;
}
/** A track of the trip a file row takes over: its place and the line it already holds. */
export interface AdoptedTrack {
id: number;
route_geometry: string;
}
/**
* The rows of a tour import that take over a track already in the trip, by
* row index: a place with a line and no tour yet, under the same name
* (trimmed, any case, the duplicate rule's own reading), one place per row
* and oldest first. A GPX file imported before Tours existed (4.x) left its
* tracks as plain places, and the name alone made every tour import of the
* same file a duplicate, with no tour to show for it (#2810).
*/
export function tracksToAdopt(
rows: ReadonlyArray<{ name: string }>,
places: ReadonlyArray<TripPlaceForAdoption>,
): Map<number, AdoptedTrack> {
const byName = new Map<string, AdoptedTrack[]>();
for (const place of [...places].sort((a, b) => a.id - b.id)) {
const key = normalizePlaceName(place.name);
if (!key || !place.route_geometry || place.tour_place_id != null) continue;
byName.set(key, [...(byName.get(key) ?? []), { id: place.id, route_geometry: place.route_geometry }]);
}
const adopt = new Map<number, AdoptedTrack>();
rows.forEach((row, index) => {
const key = normalizePlaceName(row.name);
const track = key ? byName.get(key)?.shift() : undefined;
if (track) adopt.set(index, track);
});
return adopt;
}
+140 -75
View File
@@ -16,7 +16,7 @@ import { toRowId } from '../common/row-id';
import { UnitOfWork } from '../database/unit-of-work'; import { UnitOfWork } from '../database/unit-of-work';
import { PlaceImportService } from '../place-import/place-import.service'; import { PlaceImportService } from '../place-import/place-import.service';
import { PlacesService } from '../places/places.service'; import { PlacesService } from '../places/places.service';
import { toActivity, trackTimesFor } from './tour-activities.helpers'; import { recordedTimesOf, toActivity, trackTimesFor } from './tour-activities.helpers';
import { import {
copiedFacet, copiedFacet,
copyStart, copyStart,
@@ -29,6 +29,7 @@ import { readTourFile } from './tour-file-import.helpers';
import { provenanceSource, tourItemOf } from './tour-item.helpers'; import { provenanceSource, tourItemOf } from './tour-item.helpers';
import { TourPaddleCheckService } from './tour-paddle-check.service'; import { TourPaddleCheckService } from './tour-paddle-check.service';
import { advanceTourRevision } from './tour-revision'; import { advanceTourRevision } from './tour-revision';
import { tracksToAdopt } from './tour-track-adoption';
import { toVisit } from './tour-visits.service'; import { toVisit } from './tour-visits.service';
import { checkTourCommand, firstPointOf, importFileName, keyedWaypoints, tourRouteFields } from './tour-write.helpers'; import { checkTourCommand, firstPointOf, importFileName, keyedWaypoints, tourRouteFields } from './tour-write.helpers';
import { computeTourMetrics, localHashOf, parseRouteGeometry, type GeometryPoint } from './tours.helpers'; import { computeTourMetrics, localHashOf, parseRouteGeometry, type GeometryPoint } from './tours.helpers';
@@ -58,6 +59,30 @@ import {
wandererDifficultyToSac, wandererDifficultyToSac,
} from '@trek/shared'; } 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 { export interface ImportGpxAsTourResult {
tours: TourListItem[]; tours: TourListItem[];
caution: boolean; caution: boolean;
@@ -783,88 +808,45 @@ export class ToursService {
const { rows } = await readTourFile(this.placeImport, fileBuffer, fileName); const { rows } = await readTourFile(this.placeImport, fileBuffer, fileName);
if (rows.length === 0) return null; if (rows.length === 0) return null;
await this.assertTourTypeEnabled(fields.tour_type); 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, const typeFields = importedTypeFields(fields);
// 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 adopt = await this.adoptableTracks(tripId, rows);
const fresh = rows.filter((_, index) => !adopt.has(index));
const tours: TourListItem[] = []; const tours: TourListItem[] = [];
const adopted: number[] = [];
const result = await this.uow.transactional(async () => { const result = await this.uow.transactional(async () => {
// A nested transactional, so the places and their facets commit together. // A nested transactional, so the places and their facets commit together.
const imported = await this.places.importPreparedGpx(tripId, rows); const imported =
const times = trackTimesFor(rows, imported.places); fresh.length > 0 ? await this.places.importPreparedGpx(tripId, fresh) : { places: [], count: 0, skipped: 0 };
for (const place of imported.places) { const times = trackTimesFor(fresh, imported.places);
const recorded = times.get(place.id); tours.push(
const metrics = computeTourMetrics(parseRouteGeometry(place.route_geometry)); ...(await inOrder(imported.places, (place) =>
const matchConfidence = metrics.hasElevation ? 1 : 0.3; this.insertImportedTour(
await this.toursRepo.insertTour({ place,
place_id: place.id, place.route_geometry ?? null,
tour_type: fields.tour_type, times.get(place.id),
...typeFields, fields,
distance: metrics.distanceKm, typeFields,
elevation_gain: metrics.elevationGainM, fileName,
elevation_loss: metrics.elevationLossM, ),
duration: null, )),
planned_duration_minutes: null, );
break_additional_minutes: null, // A track from before Tours keeps its place and its line and becomes the
match_confidence: matchConfidence, // tour; the file adds the recorded times 4.x never stored.
max_hiking_difficulty: null, await inOrder([...adopt], async ([index, track]) => {
origin: 'gpx', const place = await this.places.get(tripId, String(track.id));
origin_file_name: importFileName(fileName), if (!place) return;
track_started_at: recorded?.started ?? null, const recorded = recordedTimesOf(rows[index]);
track_ended_at: recorded?.ended ?? null, tours.push(await this.insertImportedTour(place, track.route_geometry, recorded, fields, typeFields, fileName));
}); adopted.push(place.id);
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,
}),
);
}
return imported; 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( this.broadcastToursChanged(
tripId, tripId,
result.places.map((place) => place.id), tours.map((tour) => tour.place_id),
socketId, socketId,
); );
for (const place of result.places) { for (const place of result.places) {
@@ -874,9 +856,90 @@ export class ToursService {
this.logger.warn(`Committed GPX place ${place.id}: realtime notification failed`); 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 }; 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 * 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 * 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); const { rows, leftOut } = await readTourFile(this.placeImport, fileBuffer, fileName);
if (rows.length === 0) return null; if (rows.length === 0) return null;
const duplicates = await this.places.gpxDuplicates(tripId, rows); const duplicates = await this.places.gpxDuplicates(tripId, rows);
const adopt = await this.adoptableTracks(tripId, rows);
return { return {
tracks: rows.map((row, index) => { tracks: rows.map((row, index) => {
const geometry = parseRouteGeometry(row.routeGeometry); const geometry = parseRouteGeometry(row.routeGeometry);
@@ -900,7 +964,8 @@ export class ToursService {
elevation_loss_m: metrics.elevationLossM, elevation_loss_m: metrics.elevationLossM,
started_at: row.trackTimes?.startedAt ?? null, started_at: row.trackTimes?.startedAt ?? null,
ended_at: row.trackTimes?.endedAt ?? 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 } : {}), ...(leftOut ? { left_out: leftOut } : {}),
+153
View File
@@ -0,0 +1,153 @@
/**
* An "Own server" that is OpenAI-compatible but not Ollama, end to end: the
* whole app against a fake server on loopback that only answers under
* `/openai/v1`, the way Groq does, and 404s every Ollama path.
*
* Covered: connecting it as the instance connection (#2808), the refusal that
* says the server answered without a model list, a refused key, and a booking
* read through it by AI parsing, which used to call Ollama's `/api/chat` and
* fail with the server's 404 (#2806).
*/
import { buildApp } from '../../src/bootstrap';
import { withRequestContext } from '../../src/nest/database/request-context';
import { LlmParseService } from '../../src/nest/llm-parse/llm-parse.service';
import { setAddonEnabled } from '../helpers/factories/settings';
import { makeUser } from '../helpers/factories/users';
import { resetRateLimits } from '../helpers/test-db';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// ── The fake server ──────────────────────────────────────────────────────────
/** How the fake answers: as Groq does, with 200 and a page for every path, or refusing every key. */
const server = { mode: 'groq' as 'groq' | 'html' | 'refuse', seen: [] as string[], chats: [] as unknown[] };
async function readBody(req: IncomingMessage): Promise<string> {
let text = '';
for await (const chunk of req) text += String(chunk);
return text;
}
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
}
async function answer(req: IncomingMessage, res: ServerResponse): Promise<void> {
server.seen.push(`${req.method} ${req.url}`);
const body = await readBody(req);
if (server.mode === 'refuse') return json(res, 401, { error: { message: 'Invalid API Key' } });
if (server.mode === 'html') {
res.writeHead(200, { 'content-type': 'text/html' });
res.end('<html><body>Sign in</body></html>');
return;
}
if (req.method === 'GET' && req.url === '/openai/v1/models') {
return json(res, 200, { object: 'list', data: [{ id: 'openai/gpt-oss-120b', object: 'model' }] });
}
if (req.method === 'POST' && req.url === '/openai/v1/chat/completions') {
server.chats.push(JSON.parse(body));
const hotel = {
name: 'Hotel Sakura',
address: '1-2-3 Asakusa, Tokyo',
checkin_time: '2026-11-02T15:00:00',
checkout_time: '2026-11-05T11:00:00',
};
const result = { result: [hotel] };
return json(res, 200, { choices: [{ message: { content: JSON.stringify(result) }, finish_reason: 'stop' }] });
}
json(res, 404, {
error: {
message: `Unknown request URL: ${req.method} ${req.url}.`,
type: 'invalid_request_error',
code: 'unknown_url',
},
});
}
// ── The app ──────────────────────────────────────────────────────────────────
let nestApp: INestApplication;
let orm: MikroORM;
let fake: HttpServer;
let adminId: number;
function connect(apiKey = 'gsk_test'): request.Test {
const { port } = fake.address() as AddressInfo;
return request(nestApp.getHttpServer())
.put('/api/admin/ai/connection')
.set('Cookie', sessionCookie(adminId))
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/openai/v1`, apiKey, model: 'openai/gpt-oss-120b' });
}
beforeAll(async () => {
fake = createServer((req, res) => {
answer(req, res).catch(() => res.destroy());
});
await new Promise<void>((resolve) => fake.listen(0, '127.0.0.1', resolve));
nestApp = await buildApp();
orm = nestApp.get(MikroORM);
await setAddonEnabled(orm, 'ai_assistant', true);
adminId = (await makeUser(orm, { role: 'admin' })).user.id;
});
beforeEach(async () => {
await resetRateLimits(nestApp);
server.mode = 'groq';
server.seen = [];
server.chats = [];
});
afterAll(async () => {
await nestApp.close();
await new Promise<void>((resolve) => fake.close(() => resolve()));
});
// ── Cases ────────────────────────────────────────────────────────────────────
describe('An OpenAI-compatible own server', () => {
it('AIOWN-E2E-001: connects, its models read from the list at the address as entered (#2808)', async () => {
const res = await connect();
expect(res.status).toBe(200);
expect(server.seen.slice(0, 2)).toEqual(['GET /openai/api/tags', 'GET /openai/v1/models']);
});
it('AIOWN-E2E-002: a server that answers without a model list is told apart from one that is down', async () => {
server.mode = 'html';
// Its own key, so the list the first case read is not taken from the catalog's cache.
const res = await connect('gsk_html');
expect(res.status).toBe(502);
expect(res.body).toMatchObject({ code: 'provider_unavailable', reason: 'no_model_list' });
});
it('AIOWN-E2E-003: a refused key reads as a refused key, not as a server that is down', async () => {
server.mode = 'refuse';
const res = await connect('wrong');
expect(res.status).toBe(422);
expect(res.body).toMatchObject({ code: 'invalid_key' });
});
it('AIOWN-E2E-004: AI parsing reads a booking through /chat/completions when /api/chat is missing (#2806)', async () => {
expect((await connect()).status).toBe(200);
await setAddonEnabled(orm, 'llm_parsing', true);
server.seen = [];
// Outside a request, so it needs its own ORM context, as a job would.
const booking = {
buffer: Buffer.from('Booking confirmation\nHotel Sakura, Tokyo\nCheck-in 2 Nov 2026, check-out 5 Nov 2026'),
originalName: 'booking.txt',
};
const out = await withRequestContext(orm, () => nestApp.get(LlmParseService).parse(booking, adminId));
expect(out.warnings.filter((w) => /failed/i.test(w))).toEqual([]);
expect(out.kiItems).toHaveLength(1);
expect(server.seen).toContain('POST /openai/api/chat');
expect(server.seen).toContain('POST /openai/v1/chat/completions');
const sent = server.chats[0] as { model: string; messages: { role: string }[] };
expect(sent.model).toBe('openai/gpt-oss-120b');
});
});
+45
View File
@@ -535,6 +535,51 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
expect(await countRows(orm, Tours)).toBe(1); 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 () => { it('400 without a file, and for a file with no track or route', async () => {
const missing = await importGpx(OWNER, null); const missing = await importGpx(OWNER, null);
expect(missing.status).toBe(400); expect(missing.status).toBe(400);
@@ -117,11 +117,11 @@ describe('PluginRuntimeService dev-link', () => {
try { try {
await expect(runtime.link(writeSource('oldplug-bypass', { trek: '>=2.0.0 <3.0.0' }))).resolves.toMatchObject({ await expect(runtime.link(writeSource('oldplug-bypass', { trek: '>=2.0.0 <3.0.0' }))).resolves.toMatchObject({
id: 'oldplug-bypass', id: 'oldplug-bypass',
trekRangeBypassed: { trekRange: '>=2.0.0 <3.0.0', hostVersion: '3.3.0' }, trekRangeBypassed: { trekRange: '>=2.0.0 <3.0.0', hostVersion: '3.3.0', acceptedByAdmin: false },
}); });
await expect(runtime.link(writeSource('rangeless-bypass', { trek: '' }))).resolves.toMatchObject({ await expect(runtime.link(writeSource('rangeless-bypass', { trek: '' }))).resolves.toMatchObject({
id: 'rangeless-bypass', id: 'rangeless-bypass',
trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0' }, trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0', acceptedByAdmin: false },
}); });
// A dir that fits is a plain link — no marker to alarm anyone with. // A dir that fits is a plain link — no marker to alarm anyone with.
await expect(runtime.link(writeSource('fits-bypass'))).resolves.toMatchObject({ trekRangeBypassed: null }); await expect(runtime.link(writeSource('fits-bypass'))).resolves.toMatchObject({ trekRangeBypassed: null });
@@ -24,7 +24,8 @@ const { testDb } = vi.hoisted(() => {
-- otherwise fail on the version gate before reaching the behaviour under test. The gate -- otherwise fail on the version gate before reaching the behaviour under test. The gate
-- itself is exercised in "TREK host-version gating" below, with explicit ranges. -- itself is exercised in "TREK host-version gating" below, with explicit ranges.
trek_range TEXT DEFAULT '>=3.0.0', trek_range TEXT DEFAULT '>=3.0.0',
source_repo TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT); source_repo TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT,
trek_range_accepted_version TEXT, trek_range_accepted_host TEXT);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
CREATE TABLE plugin_settings_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT); CREATE TABLE plugin_settings_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT);
CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT); CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT);
@@ -818,14 +819,27 @@ describe('PluginRuntimeService.retrust', () => {
* host it declared it doesn't support — would otherwise be spawned on the next boot. * host it declared it doesn't support — would otherwise be spawned on the next boot.
*/ */
describe('TREK host-version gating', () => { describe('TREK host-version gating', () => {
const seedRanged = (id: string, trekRange: string | null, enabled = 0) => { const seedRanged = (
id: string,
trekRange: string | null,
enabled = 0,
accepted: { version?: string; acceptedVersion?: string; acceptedHost?: string } = {},
) => {
fs.mkdirSync(path.join(codeRoot, id, 'server'), { recursive: true }); fs.mkdirSync(path.join(codeRoot, id, 'server'), { recursive: true });
fs.writeFileSync(path.join(codeRoot, id, 'server', 'index.js'), 'module.exports = { async onLoad() {} };'); fs.writeFileSync(path.join(codeRoot, id, 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
testDb testDb
.prepare( .prepare(
"INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range) VALUES (?, ?, ?, '[]', '[]', '{}', ?)", "INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range, version, trek_range_accepted_version, trek_range_accepted_host) VALUES (?, ?, ?, '[]', '[]', '{}', ?, ?, ?, ?)",
) )
.run(id, enabled ? 'active' : 'inactive', enabled, trekRange); .run(
id,
enabled ? 'active' : 'inactive',
enabled,
trekRange,
accepted.version ?? null,
accepted.acceptedVersion ?? null,
accepted.acceptedHost ?? null,
);
}; };
const cleanup = (...ids: string[]) => { const cleanup = (...ids: string[]) => {
for (const id of ids) testDb.prepare('DELETE FROM plugins WHERE id = ?').run(id); for (const id of ids) testDb.prepare('DELETE FROM plugins WHERE id = ?').run(id);
@@ -919,6 +933,43 @@ describe('TREK host-version gating', () => {
}); });
}); });
it("runs an outgrown plugin on the admin's say-so, and does not ask a second time", async () => {
process.env.APP_VERSION = '5.0.0';
seedRanged('say-so', '>=3.2.0 <4.0.0', 0, { version: '1.0.3' });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const rt = await createPluginRuntime(dbConn);
await rt.activate('say-so', false, true);
expect(rt.isActive('say-so')).toBe(true);
expect(warn.mock.calls.some((c) => /say-so.*an admin chose to run it anyway/.test(String(c[0])))).toBe(true);
await rt.deactivate('say-so');
// The consent was kept with the row, so switching it on again needs no second one.
await rt.activate('say-so');
expect(rt.isActive('say-so')).toBe(true);
await rt.deactivate('say-so');
warn.mockRestore();
cleanup('say-so');
});
it("keeps the admin's consent for the version and TREK major it was given on only", async () => {
const given = { acceptedVersion: '1.0.3', acceptedHost: '5.0.0' };
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
process.env.APP_VERSION = '5.0.1';
seedRanged('kept', '>=3.2.0 <4.0.0', 0, { version: '1.0.3', ...given });
seedRanged('updated', '>=3.2.0 <4.0.0', 0, { version: '1.1.0', ...given });
const rt = await createPluginRuntime(dbConn);
await rt.activate('kept');
expect(rt.isActive('kept')).toBe(true);
await rt.deactivate('kept');
// An update of the plugin asks again.
await expect(rt.activate('updated')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
// So does a new TREK major.
process.env.APP_VERSION = '6.0.0';
await expect(rt.activate('kept')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
warn.mockRestore();
cleanup('kept', 'updated');
});
it('checks the version BEFORE permissions — an unrunnable plugin gets no consent dialog', async () => { it('checks the version BEFORE permissions — an unrunnable plugin gets no consent dialog', async () => {
// Consenting to permissions would not make it start, so offering the dialog is a lie. // Consenting to permissions would not make it start, so offering the dialog is a lie.
process.env.APP_VERSION = '4.0.0'; process.env.APP_VERSION = '4.0.0';
@@ -1193,6 +1193,43 @@ describe('TREK-version gating on install', () => {
expect(safeDownload).not.toHaveBeenCalled(); expect(safeDownload).not.toHaveBeenCalled();
}); });
it("an admin's install-anyway takes the newest version when none fits, and keeps that consent on the row", async () => {
process.env.APP_VERSION = '4.0.0'; // both versions declare <4.0.0
stubMulti();
stageMulti('2.1.0', 0);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
await expect(svc.install('multi', { acceptTrekRange: true })).resolves.toEqual({
id: 'multi',
version: '2.1.0',
trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '4.0.0', acceptedByAdmin: true },
});
expect(warn.mock.calls.some((c) => /multi.*an admin chose to run it anyway/.test(String(c[0])))).toBe(true);
warn.mockRestore();
const row = await findRow(t, Plugins, { id: 'multi' });
expect(row).toMatchObject({ trek_range_accepted_version: '2.1.0', trek_range_accepted_host: '4.0.0' });
});
it("an admin's install-anyway still prefers a version that fits", async () => {
process.env.APP_VERSION = '3.3.0';
stubMulti();
stageMulti('2.0.0', 1);
await expect(svc.install('multi', { acceptTrekRange: true })).resolves.toEqual({
id: 'multi',
version: '2.0.0',
trekRangeBypassed: null,
});
expect(await findRow(t, Plugins, { id: 'multi' })).toMatchObject({ trek_range_accepted_version: null });
});
it("an admin's install-anyway covers the plugin they asked for, never a dependency", async () => {
process.env.APP_VERSION = '4.0.0';
stubMulti();
await expect(svc.resolveVersion('multi', undefined, false)).rejects.toMatchObject({
code: 'TREK_VERSION_INCOMPATIBLE',
});
await expect(svc.resolveVersion('multi', undefined, true)).resolves.toMatchObject({ version: '2.1.0' });
});
it('honours an EXCLUSIVE upper bound, which minTrekVersion alone cannot express', async () => { it('honours an EXCLUSIVE upper bound, which minTrekVersion alone cannot express', async () => {
// Both versions declare "<4.0.0" and neither carries a maxTrekVersion — under the old // Both versions declare "<4.0.0" and neither carries a maxTrekVersion — under the old
// min-only check TREK 4 looked compatible with everything. // min-only check TREK 4 looked compatible with everything.
@@ -1223,7 +1260,7 @@ describe('TREK-version gating on install', () => {
await expect(svc.install('multi')).resolves.toEqual({ await expect(svc.install('multi')).resolves.toEqual({
id: 'multi', id: 'multi',
version: '2.1.0', version: '2.1.0',
trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '3.3.0' }, trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false },
}); });
expect(warn.mock.calls.some((c) => /multi.*>=3\.4\.0 <4\.0\.0.*3\.3\.0/.test(String(c[0])))).toBe(true); expect(warn.mock.calls.some((c) => /multi.*>=3\.4\.0 <4\.0\.0.*3\.3\.0/.test(String(c[0])))).toBe(true);
warn.mockRestore(); warn.mockRestore();
@@ -1235,7 +1272,7 @@ describe('TREK-version gating on install', () => {
stageMulti('2.1.0', 0); stageMulti('2.1.0', 0);
await expect(svc.install('multi', { version: '2.1.0' })).resolves.toMatchObject({ await expect(svc.install('multi', { version: '2.1.0' })).resolves.toMatchObject({
version: '2.1.0', version: '2.1.0',
trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '3.3.0' }, trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false },
}); });
}); });
@@ -1268,7 +1305,7 @@ describe('TREK-version gating on install', () => {
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha }); safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
await expect(svc.install('flight-tracker')).resolves.toMatchObject({ await expect(svc.install('flight-tracker')).resolves.toMatchObject({
id: 'flight-tracker', id: 'flight-tracker',
trekRangeBypassed: { trekRange: '>=3.2.0 <3.4.0', hostVersion: '3.9.0' }, trekRangeBypassed: { trekRange: '>=3.2.0 <3.4.0', hostVersion: '3.9.0', acceptedByAdmin: false },
}); });
expect(fs.existsSync(path.join(codeRoot, 'flight-tracker'))).toBe(true); expect(fs.existsSync(path.join(codeRoot, 'flight-tracker'))).toBe(true);
}); });
@@ -1287,7 +1324,7 @@ describe('TREK-version gating on install', () => {
REGISTRY.plugins[0].versions[0].sha256 = sha; REGISTRY.plugins[0].versions[0].sha256 = sha;
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha }); safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
await expect(svc.install('flight-tracker')).resolves.toMatchObject({ await expect(svc.install('flight-tracker')).resolves.toMatchObject({
trekRangeBypassed: { trekRange: null, hostVersion: '3.9.0' }, trekRangeBypassed: { trekRange: null, hostVersion: '3.9.0', acceptedByAdmin: false },
}); });
}); });
@@ -1298,13 +1335,15 @@ describe('TREK-version gating on install', () => {
); );
expect(old).toMatchObject({ expect(old).toMatchObject({
id: 'my-upload', id: 'my-upload',
trekRangeBypassed: { trekRange: '>=3.0.0 <3.5.0', hostVersion: '3.9.0' }, trekRangeBypassed: { trekRange: '>=3.0.0 <3.5.0', hostVersion: '3.9.0', acceptedByAdmin: false },
}); });
fs.rmSync(old.stagingDir, { recursive: true, force: true }); fs.rmSync(old.stagingDir, { recursive: true, force: true });
const rangeless = svc.stageUpload( const rangeless = svc.stageUpload(
makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', trek: undefined }), makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', trek: undefined }),
); );
expect(rangeless).toMatchObject({ trekRangeBypassed: { trekRange: null, hostVersion: '3.9.0' } }); expect(rangeless).toMatchObject({
trekRangeBypassed: { trekRange: null, hostVersion: '3.9.0', acceptedByAdmin: false },
});
fs.rmSync(rangeless.stagingDir, { recursive: true, force: true }); fs.rmSync(rangeless.stagingDir, { recursive: true, force: true });
const fits = svc.stageUpload( const fits = svc.stageUpload(
makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration' }), makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration' }),
@@ -98,6 +98,7 @@ const UNNUMBERED = [
'Migration20200101046280_an_assistant_conversation_can_be_about_no_one_trip', 'Migration20200101046280_an_assistant_conversation_can_be_about_no_one_trip',
'Migration20200101046290_a_removed_member_cannot_rejoin_by_invite_link', 'Migration20200101046290_a_removed_member_cannot_rejoin_by_invite_link',
'Migration20200101046300_an_attached_file_stays_with_its_conversation', 'Migration20200101046300_an_attached_file_stays_with_its_conversation',
'Migration20200101046310_an_admin_can_run_a_plugin_outside_its_trek_range',
]; ];
// The last step the positional runner ever had; planLegacyBaseline refuses a // The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it. // schema_version past it.
@@ -574,20 +574,21 @@ describe('PlacesRepository.listForGpx (PL29) and existsByImageUrl (PI1)', () =>
* by, the service-level dedup tests. * by, the service-level dedup tests.
*/ */
describe('PlacesRepository.listDedupInputs (PL24)', () => { describe('PlacesRepository.listDedupInputs (PL24)', () => {
it('PLACEREPO-048: projects exactly the seven dedup columns, scoped to the trip', async () => { it('PLACEREPO-048: projects exactly the eight dedup columns, the line included (#2810), scoped to the trip', async () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id); const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id); const other = createTrip(testDb, user.id);
await insertRow(t.orm, Places, { await insertRow(t.orm, Places, {
trip: trip.id, name: 'Louvre', lat: 48.86, lng: 2.34, google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1', trip: trip.id, name: 'Louvre', lat: 48.86, lng: 2.34, google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1',
route_geometry: '[[48.86,2.34],[48.87,2.35]]',
}); });
await insertRow(t.orm, Places, { trip: trip.id, name: '', lat: 1.1, lng: 2.2 }); await insertRow(t.orm, Places, { trip: trip.id, name: '', lat: 1.1, lng: 2.2 });
createPlace(testDb, other.id, { name: 'Not this trip' }); createPlace(testDb, other.id, { name: 'Not this trip' });
const rows = await places.listDedupInputs(String(trip.id)); const rows = await places.listDedupInputs(String(trip.id));
expect(rows).toEqual([ expect(rows).toEqual([
{ name: 'Louvre', lat: 48.86, lng: 2.34, google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1' }, { name: 'Louvre', lat: 48.86, lng: 2.34, route_geometry: '[[48.86,2.34],[48.87,2.35]]', google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1' },
{ name: '', lat: 1.1, lng: 2.2, google_place_id: null, google_ftid: null, osm_id: null, amap_poi_id: null }, { name: '', lat: 1.1, lng: 2.2, route_geometry: null, google_place_id: null, google_ftid: null, osm_id: null, amap_poi_id: null },
]); ]);
}); });
+76
View File
@@ -99,6 +99,43 @@ describe('Tool: create_day_note', () => {
}); });
}); });
it('describes text as the title and time as the body, as the planner shows them', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const { tools } = await h.client.listTools();
for (const name of ['create_day_note', 'update_day_note']) {
const props = tools.find((t) => t.name === name)?.inputSchema.properties as Record<string, unknown>;
// The nullable body may come out as an anyOf, so read the property whole.
const time = JSON.stringify(props.time);
expect(JSON.stringify(props.text)).toMatch(/title/i);
expect(time).toMatch(/body/i);
expect(time).not.toMatch(/time label/i);
expect(time).toContain('"maxLength":2000');
}
});
});
it('takes a body of 2000 characters, the bound the planner allows, and refuses one more', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const body = 'b'.repeat(2000);
const result = await h.client.callTool({
name: 'create_day_note',
arguments: { tripId: trip.id, dayId: day.id, text: 'Museum day', time: body },
});
const data = parseToolResult(result) as { note: { id: number } };
expect((await dayNote(data.note.id)).time).toBe(body);
const tooLong = await h.client.callTool({
name: 'create_day_note',
arguments: { tripId: trip.id, dayId: day.id, text: 'Museum day', time: `${body}b` },
});
expect(tooLong.isError).toBe(true);
expect(await countRows(orm, DayNotes, { day: day.id })).toBe(1);
});
});
it('defaults icon to 📝', async () => { it('defaults icon to 📝', async () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id); const trip = createTrip(testDb, user.id);
@@ -220,6 +257,45 @@ describe('Tool: update_day_note', () => {
}); });
}); });
it('edits a note whose body is longer than 250 characters without shortening it', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const longBody = 'Opening hours and the ticket links. '.repeat(20);
const note = createDayNote(testDb, day.id, trip.id, { text: 'Museum day', time: longBody });
await withHarness(user.id, async (h) => {
const renamed = await h.client.callTool({
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'Museums' },
});
expect(renamed.isError).toBeFalsy();
const longer = `${longBody}Bring the pass.`;
const rewritten = await h.client.callTool({
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, time: longer },
});
expect(rewritten.isError).toBeFalsy();
const row = await dayNote(note.id);
expect(row.text).toBe('Museums');
expect(row.time).toBe(longer);
});
});
it('refuses an empty title on update and keeps the stored one', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id, { text: 'Keep me' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: '' },
});
expect(result.isError).toBe(true);
expect((await dayNote(note.id)).text).toBe('Keep me');
});
});
it('trims text whitespace', async () => { it('trims text whitespace', async () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id); const trip = createTrip(testDb, user.id);
@@ -127,6 +127,16 @@ describe('constants', () => {
}); });
}); });
it('AIHLP-031b: an own server that answered without a model list says so in reason (#2808)', () => {
const err = providerRefusal(new AiProviderError('provider_unavailable', 404, true, undefined, 'no_model_list'));
expect(err.getStatus()).toBe(502);
expect(err.toBody()).toEqual({
error: 'The server answered, but sent no model list',
code: 'provider_unavailable',
reason: 'no_model_list',
});
});
it('AIHLP-032: the host a conversation goes to', () => { it('AIHLP-032: the host a conversation goes to', () => {
expect(aiHostOf('anthropic', null)).toBe('api.anthropic.com'); expect(aiHostOf('anthropic', null)).toBe('api.anthropic.com');
expect(aiHostOf('chatgpt', null)).toBe('api.openai.com'); expect(aiHostOf('chatgpt', null)).toBe('api.openai.com');
@@ -201,6 +201,19 @@ describe('ModelCatalogService', () => {
).rejects.toMatchObject({ code: 'provider_unavailable', status: 502 }); ).rejects.toMatchObject({ code: 'provider_unavailable', status: 502 });
}); });
it('MCAT-007b: a custom server that refuses the key, or answers without a list, says so (#2808)', async () => {
const target = { kind: 'custom' as const, baseUrl: 'https://llm.example.org/v1', secret: 'k' };
llmLocal.listModels.mockRejectedValueOnce(new DomainError(502, 'rejected', { code: 'llm_key_refused' }));
await expect(catalog().list(target)).rejects.toMatchObject({ code: 'invalid_key', retryable: false });
llmLocal.listModels.mockRejectedValueOnce(new DomainError(502, 'no list', { code: 'llm_no_model_list' }));
await expect(catalog().list(target)).rejects.toMatchObject({
code: 'provider_unavailable',
providerCode: 'no_model_list',
});
llmLocal.listModels.mockRejectedValueOnce(new Error('boom'));
await expect(catalog().list(target)).rejects.toThrow('boom');
});
it('MCAT-008: the default model is a capable one, else the first listed', () => { it('MCAT-008: the default model is a capable one, else the first listed', () => {
const list = (...ids: string[]) => ids.map((id) => ({ id, label: id, supportsTools: true })); const list = (...ids: string[]) => ids.map((id) => ({ id, label: id, supportsTools: true }));
expect(pickDefaultModel('anthropic', list('claude-haiku', 'claude-sonnet-5', 'claude-opus'))).toBe( expect(pickDefaultModel('anthropic', list('claude-haiku', 'claude-sonnet-5', 'claude-opus'))).toBe(
@@ -28,6 +28,12 @@ import { toProviderSchema, type AiFetch } from '../../../../src/nest/ai-connecti
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
const { logWarn } = vi.hoisted(() => ({ logWarn: vi.fn() }));
vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../src/nest/audit/audit-log.logger')>()),
logWarn,
}));
/** An SSE body from whole events, cut every `every` characters so no event arrives in one piece. */ /** An SSE body from whole events, cut every `every` characters so no event arrives in one piece. */
function sseBody(events: unknown[], every = 7): ReadableStream<Uint8Array> { function sseBody(events: unknown[], every = 7): ReadableStream<Uint8Array> {
const text = events.map((e) => `data: ${typeof e === 'string' ? e : JSON.stringify(e)}\n\n`).join(''); const text = events.map((e) => `data: ${typeof e === 'string' ? e : JSON.stringify(e)}\n\n`).join('');
@@ -145,6 +151,40 @@ describe('provider errors', () => {
expect(errorCodeOf(null)).toBeUndefined(); expect(errorCodeOf(null)).toBeUndefined();
expect(errorCodeOf({})).toBeUndefined(); expect(errorCodeOf({})).toBeUndefined();
}); });
it('PERR-004: a refusal is logged with the path of the field it names, never with its message (#2845)', async () => {
const refused = (param: unknown) =>
failure(
new OpenAiResponsesClient(
'm',
async () => 'at',
fetchAnswering(
jsonResponse(400, {
error: {
type: 'invalid_request_error',
code: null,
message: 'No tool output found for function call call_note. Dinner in Rome at 20:00.',
param,
},
}),
).fetcher,
).stream(REQUEST, signal()),
);
logWarn.mockClear();
expect(await refused('input[3]')).toMatchObject({ code: 'generic', status: 400 });
expect(logWarn).toHaveBeenCalledWith(
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error) at input[3]',
);
logWarn.mockClear();
await refused('input[3] Dinner in Rome');
await refused(null);
expect(logWarn.mock.calls.map(([line]) => line)).toEqual([
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error)',
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error)',
]);
});
}); });
describe('toProviderSchema', () => { describe('toProviderSchema', () => {
@@ -0,0 +1,23 @@
/**
* The bundled assets/airports.json itself, not a fixture.
*
* OurAirports marks some airports with daily flights as having no scheduled
* service, and the build used to drop them on that flag alone (#2820, Little
* Cayman). These pin a few of them and the codes saved bookings already point at.
*/
import { findByIata } from '../../../../src/nest/airports/airports.data';
import { describe, it, expect } from 'vitest';
describe('bundled airport dataset', () => {
it('AIRPORT-ASSET-001: keeps airports OurAirports flags as unscheduled', () => {
expect(findByIata('LYB')?.name).toBe('Edward Bodden Little Cayman Airfield');
expect(findByIata('VEY')?.country).toBe('IS');
});
it('AIRPORT-ASSET-002: still resolves the codes it carried before', () => {
for (const code of ['PBI', 'ADA', 'GCM', 'CYB', 'FRA']) {
expect(findByIata(code), code).not.toBeNull();
}
});
});
@@ -6,6 +6,9 @@
*/ */
import type { AssistantMessageRow } from '../../../../src/db/repositories/AssistantMessages.repository'; import type { AssistantMessageRow } from '../../../../src/db/repositories/AssistantMessages.repository';
import type { AssistantToolCallRow } from '../../../../src/db/repositories/AssistantToolCalls.repository'; import type { AssistantToolCallRow } from '../../../../src/db/repositories/AssistantToolCalls.repository';
import type { AiChatMessage } from '../../../../src/nest/ai-connections/ai-chat-client.interface';
import { toAnthropicMessages } from '../../../../src/nest/ai-connections/providers/anthropic-messages.client';
import { toResponsesInput } from '../../../../src/nest/ai-connections/providers/openai-responses.client';
import { import {
messageIdOf, messageIdOf,
outputText, outputText,
@@ -249,7 +252,7 @@ describe('toChatHistory', () => {
]); ]);
}); });
it('ASTHIS-005d: the text of a file the user let the model read comes after the step, once, cut in earlier turns', () => { it('ASTHIS-005d: the text of a file the user let the model read answers its read, once, cut in earlier turns', () => {
const id = '0b7c2f0e-6f6d-4a8e-9b1a-3d2c1e0f9a87'; const id = '0b7c2f0e-6f6d-4a8e-9b1a-3d2c1e0f9a87';
type CallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>; type CallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>;
const read = (toolCallId: string, extra: Partial<CallPart>): AssistantMessagePart => ({ const read = (toolCallId: string, extra: Partial<CallPart>): AssistantMessagePart => ({
@@ -270,20 +273,27 @@ describe('toChatHistory', () => {
row('user', [{ type: 'text', text: 'Thanks' }]), row('user', [{ type: 'text', text: 'Thanks' }]),
]; ];
const history = toChatHistory(rows, new Map(), new Map([[id, long]])); const history = toChatHistory(rows, new Map(), new Map([[id, long]]));
expect(history.map((message) => message.role)).toEqual(['user', 'assistant', 'user', 'user', 'user']); expect(history.map((message) => message.role)).toEqual(['user', 'assistant', 'tool', 'assistant', 'tool', 'user']);
expect(history[1]).toMatchObject({ role: 'assistant', text: 'I need to read it first.', toolCalls: [] }); expect(history[1]).toMatchObject({
const told = (history[2] as { text: string }).text; role: 'assistant',
text: 'I need to read it first.',
toolCalls: [{ id: 'r1', name: 'read_attached_file' }],
});
expect(history[2]).toMatchObject({ role: 'tool', toolCallId: 'r1' });
const told = (history[2] as { output: string }).output;
expect(told).toContain('The user let you read the attached file "AirBnB Buchung.pdf".'); expect(told).toContain('The user let you read the attached file "AirBnB Buchung.pdf".');
expect(told).toContain('Casa Lisboa'); expect(told).toContain('Casa Lisboa');
// An earlier turn's text is cut so it does not crowd out the conversation. // An earlier turn's text is cut so it does not crowd out the conversation.
expect(told.length).toBeLessThan(long.length); expect(told.length).toBeLessThan(long.length);
expect(told.endsWith('[...]')).toBe(true); expect(told.endsWith('[...]')).toBe(true);
expect((history[3] as { text: string }).text).toBe( expect(history[4]).toMatchObject({
'You read the attached file "AirBnB Buchung.pdf" again; its text is above.', role: 'tool',
); toolCallId: 'r2',
output: 'You read the attached file "AirBnB Buchung.pdf" again; its text is above.',
});
// The latest turn keeps the whole text. // The latest turn keeps the whole text.
const latest = toChatHistory(rows.slice(0, 2), new Map(), new Map([[id, long]])); const latest = toChatHistory(rows.slice(0, 2), new Map(), new Map([[id, long]]));
expect((latest[2] as { text: string }).text).toContain(long); expect((latest[2] as { output: string }).output).toContain(long);
}); });
it('ASTHIS-005e: a read the user did not allow, one still waiting, a refused one and a swept file say so', () => { it('ASTHIS-005e: a read the user did not allow, one still waiting, a refused one and a swept file say so', () => {
@@ -303,21 +313,21 @@ describe('toChatHistory', () => {
]), ]),
], ],
new Map(), new Map(),
).map((message) => (message as { text: string }).text); ).map((message) => (message.role === 'tool' ? message.output : null));
expect(step({ approval: { id: 'r', approved: false } })[1]).toBe( expect(step({ approval: { id: 'r', approved: false } })[2]).toBe(
'The user did not let you read the attached file "a.pdf".', 'The user did not let you read the attached file "a.pdf".',
); );
expect(step({ approval: { id: 'r' } })[1]).toContain("waits for the user's agreement; you have not read it."); expect(step({ approval: { id: 'r' } })[2]).toContain("waits for the user's agreement; you have not read it.");
expect(step({ approval: { id: 'r', resolution: 'expired' } })[1]).toContain('you have not read it'); expect(step({ approval: { id: 'r', resolution: 'expired' } })[2]).toContain('you have not read it');
expect(step({ result: 'No such attachment in this conversation.', isError: true })[1]).toBe( expect(step({ result: 'No such attachment in this conversation.', isError: true })[2]).toBe(
'Reading the attached file "a.pdf" failed: No such attachment in this conversation.', 'Reading the attached file "a.pdf" failed: No such attachment in this conversation.',
); );
expect(step({ result: { read: true } })[1]).toBe( expect(step({ result: { read: true } })[2]).toBe(
'The attached file "a.pdf" is no longer kept; you cannot read it again.', 'The attached file "a.pdf" is no longer kept; you cannot read it again.',
); );
}); });
it('ASTHIS-005f: keeping an attached file is told after its step, whatever became of it', () => { it('ASTHIS-005f: keeping an attached file is answered with what became of it', () => {
const keep = (extra: Partial<Extract<AssistantMessagePart, { type: 'tool-call' }>>) => const keep = (extra: Partial<Extract<AssistantMessagePart, { type: 'tool-call' }>>) =>
toChatHistory( toChatHistory(
[ [
@@ -336,11 +346,12 @@ describe('toChatHistory', () => {
new Map(), new Map(),
); );
const kept = keep({ approval: { id: 'k', approved: true }, result: { file: { id: 31 } } }); const kept = keep({ approval: { id: 'k', approved: true }, result: { file: { id: 31 } } });
expect(kept.map((message) => message.role)).toEqual(['user', 'assistant', 'user']); expect(kept.map((message) => message.role)).toEqual(['user', 'assistant', 'tool']);
expect((kept[2] as { text: string }).text).toBe( expect(kept[2]).toMatchObject({
'The attached file "a.pdf" is in the trip\'s Files now (file id 31).', toolCallId: 'k',
); output: 'The attached file "a.pdf" is in the trip\'s Files now (file id 31).',
const lastOf = (extra: Parameters<typeof keep>[0]) => (keep(extra).at(-1) as { text: string }).text; });
const lastOf = (extra: Parameters<typeof keep>[0]) => (keep(extra).at(-1) as { output: string }).output;
expect( expect(
lastOf({ approval: { id: 'k', approved: true }, result: 'The file could not be kept.', isError: true }), lastOf({ approval: { id: 'k', approved: true }, result: 'The file could not be kept.', isError: true }),
).toBe('The attached file "a.pdf" could not be kept in Files: The file could not be kept.'); ).toBe('The attached file "a.pdf" could not be kept in Files: The file could not be kept.');
@@ -448,6 +459,151 @@ describe('toChatHistory', () => {
}); });
}); });
describe('toChatHistory: calls the model made of the server tools (#2845)', () => {
/**
* The thread of #2845: a step that kept a trip note on the way (it runs at
* once), then a step whose changes the user applied, both with the
* provider's own record of the step stored beside them.
*/
function appliedAfterANote(
noteState: Record<string, unknown>,
changeState: Record<string, unknown>,
): AiChatMessage[] {
const rows = [
row('user', [{ type: 'text', text: 'Dinner at 20:00 on day 2, and remember we like late dinners' }]),
row(
'assistant',
[
{
type: 'tool-call',
toolCallId: 'call_note',
toolName: 'remember_for_trip',
args: { note: 'Likes late dinners' },
result: { notes: 'Likes late dinners' },
},
],
{ provider_state: JSON.stringify(noteState) },
),
row(
'assistant',
[
{ type: 'text', text: 'Shall I move it?' },
{
type: 'tool-call',
toolCallId: 'held-1',
toolName: 'update_assignment_time',
args: { assignmentId: 7, place_time: '20:00' },
result: { assignment: { id: 7 } },
approval: { id: 'held-1', approved: true },
},
],
{ provider_state: JSON.stringify(changeState) },
),
];
return toChatHistory(rows, new Map([['held-1', 'call_move']]));
}
it('ASTHIS-015: the Responses API gets an answer for every call it made, a kept note included', () => {
const history = appliedAfterANote(
{
adapter: 'openai-responses',
items: [
{ type: 'reasoning', id: 'rs_1', encrypted_content: 'x' },
{ type: 'function_call', call_id: 'call_note', name: 'remember_for_trip', arguments: '{}' },
],
},
{
adapter: 'openai-responses',
items: [
{ type: 'reasoning', id: 'rs_2', encrypted_content: 'y' },
{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Shall I move it?' }] },
{ type: 'function_call', call_id: 'call_move', name: 'update_assignment_time', arguments: '{}' },
],
},
);
const input = toResponsesInput({ system: '', messages: history, tools: [] });
const made = input.filter((item) => item.type === 'function_call').map((item) => item.call_id);
const answered = input.filter((item) => item.type === 'function_call_output').map((item) => item.call_id);
expect(made).toEqual(['call_note', 'call_move']);
expect(answered).toEqual(['call_note', 'call_move']);
for (const id of made) {
const call = input.findIndex((item) => item.type === 'function_call' && item.call_id === id);
const output = input.findIndex((item) => item.type === 'function_call_output' && item.call_id === id);
expect(output).toBeGreaterThan(call);
}
expect(input.find((item) => item.type === 'function_call_output' && item.call_id === 'call_note')).toMatchObject({
output: '{"notes":"Likes late dinners"}',
});
// Nothing empty goes out as the user's: a kept note is no step of the server to tell.
const userTexts = input
.filter((item) => item.type === 'message' && item.role === 'user')
.flatMap((item) => (item.content as Array<{ text?: string }>).map((part) => part.text ?? ''));
expect(userTexts).toEqual(['Dinner at 20:00 on day 2, and remember we like late dinners']);
});
it('ASTHIS-016: Anthropic gets a tool_result for every tool_use it replays, a kept note included', () => {
const history = appliedAfterANote(
{
adapter: 'anthropic-messages',
blocks: [
{ type: 'thinking', thinking: 'note it', signature: 's1' },
{ type: 'tool_use', id: 'call_note', name: 'remember_for_trip', input: {} },
],
},
{
adapter: 'anthropic-messages',
blocks: [
{ type: 'thinking', thinking: 'move it', signature: 's2' },
{ type: 'text', text: 'Shall I move it?' },
{ type: 'tool_use', id: 'call_move', name: 'update_assignment_time', input: {} },
],
},
);
const messages = toAnthropicMessages(history);
const uses = messages.flatMap((message) => message.content).filter((block) => block.type === 'tool_use');
expect(uses.map((block) => block.id)).toEqual(['call_note', 'call_move']);
messages.forEach((message, index) => {
const ids = message.content.filter((block) => block.type === 'tool_use').map((block) => block.id);
if (ids.length === 0) return;
const next = messages[index + 1];
expect(next?.role).toBe('user');
const results = next.content.filter((block) => block.type === 'tool_result').map((block) => block.tool_use_id);
expect(results).toEqual(ids);
});
expect(messages.flatMap((message) => message.content).some((block) => block.text === '')).toBe(false);
});
it('ASTHIS-017: a step that only kept a note is the model call it was, answered with what was kept', () => {
const history = toChatHistory(
[
row('user', [{ type: 'text', text: 'Remember the ferry leaves at 7' }]),
row('assistant', [
{
type: 'tool-call',
toolCallId: 'n1',
toolName: 'remember_for_trip',
args: { note: 'Ferry at 7' },
result: { notes: 'Ferry at 7' },
},
]),
row('assistant', [{ type: 'text', text: 'Noted.' }]),
],
new Map(),
);
expect(history).toEqual([
{ role: 'user', text: 'Remember the ferry leaves at 7' },
{
role: 'assistant',
text: '',
toolCalls: [{ id: 'n1', name: 'remember_for_trip', args: { note: 'Ferry at 7' } }],
providerState: null,
},
{ role: 'tool', toolCallId: 'n1', name: 'remember_for_trip', output: '{"notes":"Ferry at 7"}' },
{ role: 'assistant', text: 'Noted.', toolCalls: [], providerState: null },
]);
});
});
describe('toThreadMessages', () => { describe('toThreadMessages', () => {
it('ASTHIS-010: the steps of one answer are one message under the first row id', () => { it('ASTHIS-010: the steps of one answer are one message under the first row id', () => {
nextId = 10; nextId = 10;
@@ -142,7 +142,7 @@ describe('describeToolCall', () => {
}); });
expect(describe_('update_day_note', { dayId: 31, noteId: 41, time: null })).toMatchObject({ expect(describe_('update_day_note', { dayId: 31, noteId: 41, time: null })).toMatchObject({
subject: 'Rail pass', subject: 'Rail pass',
rows: [{ op: 'change', labelKey: 'time', before: '09:00', after: '' }], rows: [{ op: 'change', labelKey: 'content', before: '09:00', after: '' }],
}); });
expect(describe_('create_day_note', { dayId: 33, text: 'Laundry', icon: '🧺' }).rows).toEqual([ expect(describe_('create_day_note', { dayId: 33, text: 'Laundry', icon: '🧺' }).rows).toEqual([
{ op: 'add', labelKey: 'day', before: null, after: 'Spare day' }, { op: 'add', labelKey: 'day', before: null, after: 'Spare day' },
@@ -247,7 +247,7 @@ describe('what the engine reads', () => {
expect(travel.entries.map((e) => [e.id, e.title, e.mood])).toEqual([[entry.id, 'Alfama', 'amazing']]); expect(travel.entries.map((e) => [e.id, e.title, e.mood])).toEqual([[entry.id, 'Alfama', 'amazing']]);
expect(travel.locale).toBe('de-DE'); expect(travel.locale).toBe('de-DE');
expect(travel.labels).toMatchObject({ day: 'TAG {day}', stations: 'Stationen', pros: 'Pro', cons: 'Contra' }); expect(travel.labels).toMatchObject({ day: 'TAG {day}', stations: 'Unterwegs', pros: 'Pro', cons: 'Contra' });
expect(travel.path).toEqual([]); expect(travel.path).toEqual([]);
expect((await edit.wordsFor(owner.id)).locale).toBe('de-DE'); expect((await edit.wordsFor(owner.id)).locale).toBe('de-DE');
}); });
@@ -21,7 +21,7 @@ describe('the book words', () => {
expect(french.locale).toBe('fr-FR'); expect(french.locale).toBe('fr-FR');
expect(french.labels).toMatchObject({ expect(french.labels).toMatchObject({
day: 'JOUR {day}', day: 'JOUR {day}',
stations: 'Étapes', stations: 'En chemin',
summary: 'Résumé', summary: 'Résumé',
countries: 'Pays', countries: 'Pays',
}); });
@@ -30,7 +30,7 @@ describe('the book words', () => {
locale: 'en-US', locale: 'en-US',
labels: { labels: {
day: 'DAY {day}', day: 'DAY {day}',
stations: 'Stations', stations: 'Along the way',
summary: 'Summary', summary: 'Summary',
countries: 'Countries', countries: 'Countries',
distance: 'Distance', distance: 'Distance',
@@ -96,11 +96,63 @@ describe('LlmLocalService.listModels', () => {
expect(fetchFn.mock.calls[1][1].headers).toMatchObject({ authorization: 'Bearer k' }); expect(fetchFn.mock.calls[1][1].headers).toMatchObject({ authorization: 'Bearer k' });
}); });
it('keeps the 404 when the OpenAI model list is missing too', async () => { it('says no model list came, with the first status, when no list exists anywhere', async () => {
mockFetch(async () => ({ ok: false, status: 404, body: null })); const fetchFn = mockFetch(async () => ({ ok: false, status: 404, body: null }));
await expect(svc().listModels('http://nothing:1')).rejects.toMatchObject({ await expect(svc().listModels('http://nothing:1')).rejects.toMatchObject({
response: { error: 'Local LLM server error (404)' }, code: 'llm_no_model_list',
response: { error: 'Local LLM server sent no model list (404)' },
}); });
expect(fetchFn.mock.calls.map((c: unknown[]) => c[0])).toEqual([
'http://nothing:1/api/tags',
'http://nothing:1/models',
'http://nothing:1/v1/models',
]);
});
it('reads past an /api/tags that answers 200 without a model list, as LM Studio does (#2808)', async () => {
mockFetch(async (url?: string) =>
url?.endsWith('/api/tags')
? { ok: true, status: 200, json: async () => ({ error: 'Unexpected endpoint or method. (GET /api/tags)' }) }
: { ok: true, status: 200, json: async () => ({ data: [{ id: 'qwen3-8b' }] }) },
);
expect((await svc().listModels('http://lmstudio:1234/v1')).models).toEqual([{ name: 'qwen3-8b', size: 0 }]);
});
it('asks the address as entered when it does not end in /v1 (#2808)', async () => {
const fetchFn = mockFetch(async (url?: string) =>
url === 'https://owui.example.org/api/models'
? { ok: true, status: 200, json: async () => ({ data: [{ id: 'llama3.3' }] }) }
: { ok: false, status: 404, body: null },
);
expect((await svc().listModels('https://owui.example.org/api/', 'k')).models).toEqual([
{ name: 'llama3.3', size: 0 },
]);
expect(fetchFn.mock.calls.map((c: unknown[]) => c[0])).toEqual([
'https://owui.example.org/api/api/tags',
'https://owui.example.org/api/models',
]);
});
it('reports a refused key even when the refusal came from a later list', async () => {
mockFetch(async (url?: string) =>
url?.endsWith('/api/tags') ? { ok: false, status: 404, body: null } : { ok: false, status: 401, body: null },
);
await expect(svc().listModels('https://api.groq.com/openai/v1', 'bad')).rejects.toMatchObject({
code: 'llm_key_refused',
response: { error: 'Local LLM server rejected the API key (401)' },
});
});
it('skips a later list that cannot be reached, and a body that is not JSON', async () => {
safeFetchLlmMock
.mockImplementationOnce(async () => ({
ok: true,
status: 200,
json: async () => Promise.reject(new Error('html')),
}))
.mockImplementationOnce(() => Promise.reject(new Error('ECONNRESET')))
.mockImplementationOnce(async () => ({ ok: true, status: 200, json: async () => ({ data: [{ id: 'm' }] }) }));
expect((await svc().listModels('http://vllm:8000')).models).toEqual([{ name: 'm', size: 0 }]);
}); });
it('502s when the server is unreachable', async () => { it('502s when the server is unreachable', async () => {
@@ -95,4 +95,79 @@ describe('extractEnforced', () => {
mockFetch(() => jsonResponse({ error: 'model not found' }, false, 404)); mockFetch(() => jsonResponse({ error: 'model not found' }, false, 404));
await expect(extractEnforced(INPUT)).rejects.toThrow(/Ollama \/api\/chat failed \(404\)/); await expect(extractEnforced(INPUT)).rejects.toThrow(/Ollama \/api\/chat failed \(404\)/);
}); });
it('throws for a server error without trying another path', async () => {
const fetchFn = mockFetch(() => jsonResponse({ error: 'boom' }, false, 500));
await expect(extractEnforced(INPUT)).rejects.toThrow(/failed \(500\)/);
expect(fetchFn).toHaveBeenCalledTimes(1);
});
});
describe('extractEnforced on an OpenAI-compatible server (#2806)', () => {
const GROQ = { ...INPUT, baseUrl: 'https://api.groq.com/openai/v1', model: 'openai/gpt-oss-120b', apiKey: 'gsk' };
const chatAnswer = (content: string) => jsonResponse({ choices: [{ message: { content } }] });
const unknownUrl = (url: string) =>
jsonResponse(
{ error: { message: `Unknown request URL: POST ${new URL(url).pathname}.`, code: 'unknown_url' } },
false,
404,
);
it('sends the extraction to /chat/completions at the address as entered when /api/chat is missing', async () => {
const fetchFn = mockFetch((url = '') =>
url.endsWith('/api/chat') ? unknownUrl(url) : chatAnswer('{"result":[{"name":"Hotel"}]}'),
);
expect(await extractEnforced(GROQ)).toEqual({ name: 'Hotel' });
expect(fetchFn.mock.calls.map((c) => c[0])).toEqual([
'https://api.groq.com/openai/api/chat',
'https://api.groq.com/openai/v1/chat/completions',
]);
const init = fetchFn.mock.calls[1][1] as RequestInit;
expect((init.headers as Record<string, string>).authorization).toBe('Bearer gsk');
const body = JSON.parse(init.body as string);
expect(body.model).toBe('openai/gpt-oss-120b');
expect(body.response_format.json_schema.schema).toEqual({
type: 'object',
properties: { result: { type: 'array', items: { type: 'object' } } },
required: ['result'],
});
expect(body.messages[0].content).toContain('{"result": [object]}');
expect(body.messages[1].content[0].text).toBe('doc');
});
it('falls back on a 405 or 501 too, and on a 200 that is not an Ollama answer (LM Studio)', async () => {
const fallsBackAfter = async (first: Response) => {
mockFetch((url = '') => (url.endsWith('/api/chat') ? first : chatAnswer('{"result":[{"a":1}]}')));
expect(await extractEnforced(GROQ)).toEqual({ a: 1 });
};
await fallsBackAfter(jsonResponse({}, false, 405));
await fallsBackAfter(jsonResponse({}, false, 501));
await fallsBackAfter(jsonResponse({ error: 'Unexpected endpoint' }));
});
it('answers null when the server returns no object', async () => {
mockFetch((url = '') => (url.endsWith('/api/chat') ? unknownUrl(url) : chatAnswer('{"result":[]}')));
expect(await extractEnforced(GROQ)).toBeNull();
});
it('passes the images on as image parts with their type', async () => {
const fetchFn = mockFetch((url = '') =>
url.endsWith('/api/chat') ? unknownUrl(url) : chatAnswer('{"result":[{}]}'),
);
await extractEnforced({ ...GROQ, images: ['iVBORw0K', '/9j/4AAQ', 'UklGRiQA', 'R0lGODlh'] });
const parts = JSON.parse((fetchFn.mock.calls[1][1] as RequestInit).body as string).messages[1].content;
expect(parts.slice(1).map((p: { image_url: { url: string } }) => p.image_url.url.split(';')[0])).toEqual([
'data:image/png',
'data:image/jpeg',
'data:image/webp',
'data:image/gif',
]);
});
it('reports the OpenAI-compatible failure when that path fails too', async () => {
mockFetch((url = '') =>
url.endsWith('/api/chat') ? unknownUrl(url) : jsonResponse({ error: 'bad model' }, false, 404),
);
await expect(extractEnforced(GROQ)).rejects.toThrow(/LLM request failed \(404\)/);
});
}); });
@@ -5,6 +5,7 @@
*/ */
import { import {
COORD_DEDUP_TOLERANCE, COORD_DEDUP_TOLERANCE,
dedupSetOf,
externalIdsOf, externalIdsOf,
isPlaceDuplicate, isPlaceDuplicate,
mapWithConcurrency, mapWithConcurrency,
@@ -25,6 +26,29 @@ vi.mock('../../../src/db/database', () => ({
const emptyDedup = (): DedupSet => ({ names: new Set(), coords: [], externalIds: new Set() }); const emptyDedup = (): DedupSet => ({ names: new Set(), coords: [], externalIds: new Set() });
describe('isPlaceDuplicate / trackInsertedInDedupSet', () => { describe('isPlaceDuplicate / trackInsertedInDedupSet', () => {
it('matches a track only against the tracks of that name, never a sight of that name (#2810)', () => {
// The watch named the hike after the gorge, and the gorge is already a place of the trip.
const dedup = dedupSetOf([
{ name: 'Hokitika Gorge', lat: -42.95, lng: 171.0, route_geometry: null },
{ name: ' 008 ', lat: 1, lng: 2, route_geometry: '[[1,2],[3,4]]' },
]);
expect(isPlaceDuplicate({ name: 'hokitika gorge', lat: -42.9, lng: 171.0, track: true }, dedup)).toBe(false);
expect(isPlaceDuplicate({ name: '008', lat: 1, lng: 2, track: true }, dedup)).toBe(true);
// A pin still matches every place of its name, a track included.
expect(isPlaceDuplicate({ name: 'Hokitika Gorge', lat: null, lng: null }, dedup)).toBe(true);
expect(isPlaceDuplicate({ name: '008', lat: null, lng: null }, dedup)).toBe(true);
// A track imported earlier in the same file counts for the next one.
trackInsertedInDedupSet({ name: 'Hokitika Gorge', lat: -42.9, lng: 171.0, track: true }, dedup);
expect(isPlaceDuplicate({ name: 'Hokitika gorge ', lat: null, lng: null, track: true }, dedup)).toBe(true);
// A set built by hand without tracks matches no track by name.
const bare = emptyDedup();
trackInsertedInDedupSet({ name: 'Ridge', lat: 1, lng: 2 }, bare);
expect(isPlaceDuplicate({ name: 'Ridge', lat: 1, lng: 2, track: true }, bare)).toBe(false);
trackInsertedInDedupSet({ name: 'Lake', lat: 1, lng: 2, track: true }, bare);
expect([...(bare.trackNames ?? [])]).toEqual(['lake']);
});
it('matches a named place case- and whitespace-insensitively', () => { it('matches a named place case- and whitespace-insensitively', () => {
const dedup = emptyDedup(); const dedup = emptyDedup();
trackInsertedInDedupSet({ name: ' Eiffel Tower ', lat: 1, lng: 2 }, dedup); trackInsertedInDedupSet({ name: ' Eiffel Tower ', lat: 1, lng: 2 }, dedup);
@@ -6,10 +6,12 @@
* line is written as GPX. * line is written as GPX.
*/ */
import { PlaceImportService } from '../../../src/nest/place-import/place-import.service'; import { PlaceImportService } from '../../../src/nest/place-import/place-import.service';
import { PlacesService } from '../../../src/nest/places/places.service';
import { TourFileReadService } from '../../../src/nest/tours/tour-file-read.service'; import { TourFileReadService } from '../../../src/nest/tours/tour-file-read.service';
import { gpxOfLine, splitGpxTracks } from '../../../src/nest/tours/tour-file-split.helpers'; import { gpxOfLine, splitGpxTracks } from '../../../src/nest/tours/tour-file-split.helpers';
import { TourFilesModule } from '../../../src/nest/tours/tour-files.module'; import { TourFilesModule } from '../../../src/nest/tours/tour-files.module';
import { createSnapshotTestDb } from '../../helpers/db-mock'; import { createSnapshotTestDb } from '../../helpers/db-mock';
import { makePlace } from '../../helpers/factories/places';
import { makeTrip } from '../../helpers/factories/trips'; import { makeTrip } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users'; import { makeUser } from '../../helpers/factories/users';
import { createTestModule, type TestModule } from '../../helpers/test-module'; import { createTestModule, type TestModule } from '../../helpers/test-module';
@@ -18,7 +20,7 @@ import { TOUR_GPX_TEXT_MAX } from '@trek/shared';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
// TOUR-FILE-READ-001 to TOUR-FILE-READ-008 // TOUR-FILE-READ-001 to TOUR-FILE-READ-009
const db = createSnapshotTestDb(); const db = createSnapshotTestDb();
let t: TestModule; let t: TestModule;
@@ -130,6 +132,25 @@ describe('TourFileReadService', () => {
}); });
}); });
it('TOUR-FILE-READ-009: a track from before Tours under the same name is proposed, for the import to take over (#2810)', async () => {
const trip = await tripId();
// 4.x stored a GPX track as a plain place: a line, no tours row.
await makePlace(t.get(MikroORM), trip, {
name: 'Saturday',
lat: 47.5,
lng: 11.1,
route_geometry: '[[47.5,11.1,800],[47.51,11.1,1000]]',
});
const rows = t.get(PlaceImportService).readGpx(Buffer.from(TWO_TRACKS, 'utf8'), { importTracks: true });
expect((await t.get(PlacesService).gpxDuplicates(String(trip), rows)).some(Boolean)).toBe(true);
const read = await files.read(trip, Buffer.from(TWO_TRACKS, 'utf8'), 'weekend.gpx');
expect(read.tracks.map((track) => [track.name, track.duplicate])).toEqual([
['Saturday', false],
['Sunday', false],
]);
});
it('TOUR-FILE-READ-008: a track longer than one import takes is named but carries no text', async () => { it('TOUR-FILE-READ-008: a track longer than one import takes is named but carries no text', async () => {
const points = Array.from( const points = Array.from(
{ length: 20_000 }, { length: 20_000 },
@@ -0,0 +1,47 @@
/**
* tracksToAdopt (#2810): which rows of a tour import take over a track that a
* GPX import before Tours left as a plain place.
*/
import { tracksToAdopt } from '../../../src/nest/tours/tour-track-adoption';
import { describe, expect, it } from 'vitest';
const LINE = '[[46.1,11.1,1200],[46.2,11.2,1300]]';
const track = (
id: number,
name: string,
extra: { route_geometry?: string | null; tour_place_id?: number | null } = {},
) => ({
id,
name,
route_geometry: LINE,
tour_place_id: null,
...extra,
});
const rows = (...names: string[]) => names.map((name) => ({ name }));
describe('tracksToAdopt', () => {
it('TRACK-ADOPT-001: pairs a row with a track of the same name, trimmed and in any case', () => {
expect([...tracksToAdopt(rows('GARMIN HIKE'), [track(4, ' Garmin hike ')])]).toEqual([
[0, { id: 4, route_geometry: LINE }],
]);
});
it('TRACK-ADOPT-002: gives each track to one row only, oldest first', () => {
const adopt = tracksToAdopt(rows('Hike', 'hike', 'HIKE'), [track(9, 'hike'), track(3, 'Hike')]);
expect([...adopt]).toEqual([
[0, { id: 3, route_geometry: LINE }],
[1, { id: 9, route_geometry: LINE }],
]);
});
it('TRACK-ADOPT-003: leaves out a tour, a place without a line, an unnamed place and an unnamed row', () => {
const places = [
track(1, 'Ridge', { tour_place_id: 1 }),
track(2, 'Cafe', { route_geometry: null }),
track(3, ' '),
track(4, 'Lake'),
];
expect([...tracksToAdopt(rows('Ridge', 'Cafe', '', 'Other'), places)]).toEqual([]);
});
});
@@ -27,8 +27,8 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { ToursService } from '../../../src/nest/tours/tours.service'; import { ToursService } from '../../../src/nest/tours/tours.service';
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service'; import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
import { accommodationsOver } from '../../helpers/accommodations-service'; import { accommodationsOver } from '../../helpers/accommodations-service';
import { createTrip, createUser } from '../../helpers/factories'; import { createPlace, createTrip, createUser } from '../../helpers/factories';
import { countRows, findRows } from '../../helpers/factories/rows'; import { countRows, findRows, updateRows } from '../../helpers/factories/rows';
import { noPaddleChecks } from '../../helpers/fake-paddle-checks'; import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
import { tourStageDays } from '../../helpers/fake-tour-stage-days'; import { tourStageDays } from '../../helpers/fake-tour-stage-days';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
@@ -86,7 +86,7 @@ const mixedGpx = Buffer.from(`<gpx>
</trkseg><trkseg><trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt></trkseg></trk> </trkseg><trkseg><trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt></trkseg></trk>
</gpx>`); </gpx>`);
const TRIGGERS = ['fail_facet', 'fail_place', 'fail_color']; const TRIGGERS = ['fail_facet', 'fail_place', 'fail_color', 'fail_adopt'];
/** The collaborator set the container hands PlacesService (the places.service.test.ts builder). */ /** The collaborator set the container hands PlacesService (the places.service.test.ts builder). */
async function makePlacesService(): Promise<PlacesService> { async function makePlacesService(): Promise<PlacesService> {
@@ -314,6 +314,21 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
await expectEmpty(); await expectEmpty();
}); });
it('rolls the new places back too when taking over a track from before Tours fails (#2810)', async () => {
const legacy = createPlace(testDb, Number(tripId), { name: 'Ridge' }).id;
await updateRows(
await sharedTestOrm(testDb),
Places,
{ id: legacy },
{ route_geometry: '[[49,12,100],[49.01,12.01,150]]' },
);
testDb.exec(`CREATE TRIGGER fail_adopt BEFORE INSERT ON tours WHEN NEW.place_id = ${legacy}
BEGIN SELECT RAISE(ABORT, 'adopt failure'); END;`);
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('adopt failure');
expect(await Promise.all([count('places'), count('tours')])).toEqual([1, 0]);
expect(broadcast).not.toHaveBeenCalled();
});
it('rolls back carriers and earlier colors when later coloring fails', async () => { it('rolls back carriers and earlier colors when later coloring fails', async () => {
testDb.exec(`CREATE TRIGGER fail_color BEFORE UPDATE OF route_color ON places WHEN NEW.name = 'Ridge' testDb.exec(`CREATE TRIGGER fail_color BEFORE UPDATE OF route_color ON places WHEN NEW.name = 'Ridge'
BEGIN SELECT RAISE(ABORT, 'color failure'); END;`); BEGIN SELECT RAISE(ABORT, 'color failure'); END;`);
+37 -3
View File
@@ -6,6 +6,7 @@ import {
hostSatisfies, hostSatisfies,
trekRangeBypassed, trekRangeBypassed,
bypassedRange, bypassedRange,
acceptanceHolds,
__resetHostWarningForTests, __resetHostWarningForTests,
} from '../../../src/nest/plugins/install/host-compat'; } from '../../../src/nest/plugins/install/host-compat';
@@ -125,6 +126,27 @@ describe('hostSatisfies', () => {
* the truth (hostSatisfies) never changes — the UI still has to say the plugin is * the truth (hostSatisfies) never changes — the UI still has to say the plugin is
* outside its range. * outside its range.
*/ */
describe('acceptanceHolds', () => {
it('holds for the same plugin version on the same TREK major only', () => {
process.env.APP_VERSION = '5.0.1';
const given = { acceptedVersion: '1.0.3', acceptedHost: '5.0.0' };
expect(acceptanceHolds('1.0.3', given)).toBe(true);
expect(acceptanceHolds('1.1.0', given)).toBe(false); // an update asks again
process.env.APP_VERSION = '6.0.0';
expect(acceptanceHolds('1.0.3', given)).toBe(false); // so does a new TREK major
});
it('holds nothing without a full consent or a version to compare', () => {
process.env.APP_VERSION = '5.0.0';
expect(acceptanceHolds('1.0.0', null)).toBe(false);
expect(acceptanceHolds('1.0.0', { acceptedVersion: '1.0.0', acceptedHost: null })).toBe(false);
expect(acceptanceHolds(null, { acceptedVersion: '1.0.0', acceptedHost: '5.0.0' })).toBe(false);
expect(acceptanceHolds('1.0.0', { acceptedVersion: '1.0.0', acceptedHost: 'not-a-version' })).toBe(false);
process.env.APP_VERSION = 'dev';
expect(acceptanceHolds('1.0.0', { acceptedVersion: '1.0.0', acceptedHost: '5.0.0' })).toBe(false);
});
});
describe('range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => { describe('range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
it('is off by default and only an explicit truthy value turns it on', () => { it('is off by default and only an explicit truthy value turns it on', () => {
expect(trekRangeBypassed()).toBe(false); expect(trekRangeBypassed()).toBe(false);
@@ -138,15 +160,27 @@ describe('range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
process.env.APP_VERSION = '3.3.0'; process.env.APP_VERSION = '3.3.0';
expect(bypassedRange('>=4.0.0')).toBeNull(); // off → the gate refuses, nothing to report expect(bypassedRange('>=4.0.0')).toBeNull(); // off → the gate refuses, nothing to report
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = 'true'; process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = 'true';
expect(bypassedRange('>=4.0.0')).toEqual({ trekRange: '>=4.0.0', hostVersion: '3.3.0' }); expect(bypassedRange('>=4.0.0')).toEqual({ trekRange: '>=4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false });
expect(bypassedRange('>=3.2.0 <4.0.0')).toBeNull(); // fits → not a bypass, a normal install expect(bypassedRange('>=3.2.0 <4.0.0')).toBeNull(); // fits → not a bypass, a normal install
}); });
it('covers a plugin that declared no range at all — the same author oversight', () => { it('covers a plugin that declared no range at all — the same author oversight', () => {
process.env.APP_VERSION = '3.3.0'; process.env.APP_VERSION = '3.3.0';
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1'; process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
expect(bypassedRange(null)).toEqual({ trekRange: null, hostVersion: '3.3.0' }); expect(bypassedRange(null)).toEqual({ trekRange: null, hostVersion: '3.3.0', acceptedByAdmin: false });
expect(bypassedRange(undefined)).toEqual({ trekRange: null, hostVersion: '3.3.0' }); expect(bypassedRange(undefined)).toEqual({ trekRange: null, hostVersion: '3.3.0', acceptedByAdmin: false });
});
it("an admin's consent for one plugin lets it through without the switch, and says it was theirs", () => {
process.env.APP_VERSION = '5.0.0';
expect(bypassedRange('>=3.2.0 <4.0.0', true)).toEqual({
trekRange: '>=3.2.0 <4.0.0',
hostVersion: '5.0.0',
acceptedByAdmin: true,
});
expect(bypassedRange('>=5.0.0', true)).toBeNull(); // fits: nothing was set aside
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
expect(bypassedRange('>=3.2.0 <4.0.0', true)).toMatchObject({ acceptedByAdmin: false });
}); });
it('leaves hostSatisfies telling the truth while bypassed', () => { it('leaves hostSatisfies telling the truth while bypassed', () => {
@@ -134,10 +134,32 @@ describe('PluginsService.list', () => {
expect(out.ignoreTrekRange).toBe(true); expect(out.ignoreTrekRange).toBe(true);
expect(out.plugins[0]).toMatchObject({ expect(out.plugins[0]).toMatchObject({
dependencyStatus: 'ok', dependencyStatus: 'ok',
trekRangeBypassed: { trekRange: '>=3.0.0 <4.0.0', hostVersion: '4.1.0' }, trekRangeBypassed: { trekRange: '>=3.0.0 <4.0.0', hostVersion: '4.1.0', acceptedByAdmin: false },
}); });
}); });
it("an admin's consent for this version lets the outgrown plugin activate and names it as theirs", async () => {
process.env.APP_VERSION = '4.1.0';
await insertRow(orm, Plugins, {
id: 'old',
name: 'Old',
type: 'widget',
status: 'inactive',
version: '1.0.0',
trek_range: '>=3.0.0 <4.0.0',
trek_range_accepted_version: '1.0.0',
trek_range_accepted_host: '4.0.0',
});
const out = await (await makeService()).list();
expect(out.ignoreTrekRange).toBe(false);
expect(out.plugins[0]).toMatchObject({
dependencyStatus: 'ok',
trekRangeBypassed: { trekRange: '>=3.0.0 <4.0.0', hostVersion: '4.1.0', acceptedByAdmin: true },
});
// The consent stays on the server: the list does not hand its columns out.
expect(out.plugins[0]).not.toHaveProperty('trek_range_accepted_version');
});
it('a plugin inside its range carries no marker even with the switch on', async () => { it('a plugin inside its range carries no marker even with the switch on', async () => {
process.env.APP_VERSION = '3.5.0'; process.env.APP_VERSION = '3.5.0';
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1'; process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
+3
View File
@@ -154,6 +154,9 @@ export const ASSISTANT_CHANGE_LABEL_KEYS = [
'currency', 'currency',
'stopType', 'stopType',
'text', 'text',
// A day note's heading and its body; `text` and `time` stay for rows already stored.
'title',
'content',
'icon', 'icon',
'color', 'color',
'position', 'position',
+11 -1
View File
@@ -646,7 +646,17 @@ const admin: TranslationStrings = {
'لا تعلن «{name}» أي إصدارات TREK تدعمها؛ يعمل هذا الخادم بالإصدار {host}. يسمح TREK بذلك فقط لأن TREK_PLUGINS_IGNORE_TREK_RANGE مضبوط. لا شيء يدل على أن المطوّر اختبرها على هذا الإصدار من TREK، لذا لا يوجد ضمان أنها ستعمل — وفي حالات نادرة قد تُتلف إضافة غير متوافقة بيانات TREK. تابع فقط إذا كنت تقبل هذه المخاطرة.', 'لا تعلن «{name}» أي إصدارات TREK تدعمها؛ يعمل هذا الخادم بالإصدار {host}. يسمح TREK بذلك فقط لأن TREK_PLUGINS_IGNORE_TREK_RANGE مضبوط. لا شيء يدل على أن المطوّر اختبرها على هذا الإصدار من TREK، لذا لا يوجد ضمان أنها ستعمل — وفي حالات نادرة قد تُتلف إضافة غير متوافقة بيانات TREK. تابع فقط إذا كنت تقبل هذه المخاطرة.',
'admin.plugins.dep.trekBypassed': 'خارج نطاق TREK الخاص بها ({range}) — فحوصات الإصدار متوقفة', 'admin.plugins.dep.trekBypassed': 'خارج نطاق TREK الخاص بها ({range}) — فحوصات الإصدار متوقفة',
'admin.plugins.dep.trekBypassedUnknown': 'لا تعلن أي نطاق TREK — فحوصات الإصدار متوقفة', 'admin.plugins.dep.trekBypassedUnknown': 'لا تعلن أي نطاق TREK — فحوصات الإصدار متوقفة',
'admin.plugins.incompatible': 'غير متوافق', 'admin.plugins.rangeBypass.titleAdmin': 'غير مختبَر على هذا الإصدار من TREK',
'admin.plugins.rangeBypass.bodyAdmin':
'بحسب مطوّره، يدعم «{name}» إصدارات TREK {range}، بينما يعمل هذا الخادم بالإصدار {host}. لم يؤكد المطوّر توافقه مع هذا الإصدار بعد، لذا قد لا يعمل، وفي حالات نادرة قد تتلف إضافة غير متوافقة بيانات TREK. يمكنك استخدامها رغم ذلك: يسري اختيارك على هذه الإضافة في هذا الإصدار فقط، وسيسألك TREK مجددًا بعد أي تحديث.',
'admin.plugins.rangeBypass.bodyAdminUnknown':
'لا يذكر «{name}» إصدارات TREK التي يدعمها، بينما يعمل هذا الخادم بالإصدار {host}. لا شيء يشير إلى أنه اختُبر على هذا الإصدار، لذا قد لا يعمل، وفي حالات نادرة قد تتلف إضافة غير متوافقة بيانات TREK. يمكنك استخدامها رغم ذلك: يسري اختيارك على هذه الإضافة في هذا الإصدار فقط، وسيسألك TREK مجددًا بعد أي تحديث.',
'admin.plugins.dep.trekAccepted': 'مصمَّمة لـ TREK {range}، تعمل على مسؤوليتك',
'admin.plugins.dep.trekAcceptedUnknown': 'لا يوجد إصدار TREK محدد، تعمل على مسؤوليتك',
'admin.plugins.activateAnyway': 'تفعيل رغم ذلك',
'admin.plugins.untestedChip': 'لم يُؤكَّد توافقه مع TREK {host} بعد',
'admin.plugins.filters': 'تصفية',
'admin.plugins.security.short': 'كيف تُحمى الإضافات',
'admin.plugins.accessTitle': 'ما الذي يمكنها الوصول إليه', 'admin.plugins.accessTitle': 'ما الذي يمكنها الوصول إليه',
'admin.plugins.connectsTitle': 'يتصل بـ', 'admin.plugins.connectsTitle': 'يتصل بـ',
'admin.plugins.detailsTitle': 'التفاصيل', 'admin.plugins.detailsTitle': 'التفاصيل',
+4
View File
@@ -39,6 +39,8 @@ const assistant: TranslationStrings = {
'assistant.connect.openAssistant': 'فتح المساعد', 'assistant.connect.openAssistant': 'فتح المساعد',
'assistant.connect.badKey': 'هذا المفتاح لا يعمل.', 'assistant.connect.badKey': 'هذا المفتاح لا يعمل.',
'assistant.connect.unknownModel': 'هذا النموذج غير متاح هنا.', 'assistant.connect.unknownModel': 'هذا النموذج غير متاح هنا.',
'assistant.connect.noModelList':
'استجاب الخادم، لكنه لم يرسل قائمة بالنماذج. تحقّق من العنوان، فهو ينتهي عادةً بـ /v1.',
'assistant.connect.getKey': 'الحصول على مفتاح', 'assistant.connect.getKey': 'الحصول على مفتاح',
'assistant.connect.hintAnthropic': 'تتضمن باقتا Claude Max وTeam رصيدًا لـ API.', 'assistant.connect.hintAnthropic': 'تتضمن باقتا Claude Max وTeam رصيدًا لـ API.',
'assistant.connect.hintOpenai': 'أنشئ مفتاحًا في لوحة تحكم OpenAI.', 'assistant.connect.hintOpenai': 'أنشئ مفتاحًا في لوحة تحكم OpenAI.',
@@ -189,6 +191,8 @@ const assistant: TranslationStrings = {
'assistant.change.label.currency': 'العملة', 'assistant.change.label.currency': 'العملة',
'assistant.change.label.stopType': 'نوع التوقف', 'assistant.change.label.stopType': 'نوع التوقف',
'assistant.change.label.text': 'النص', 'assistant.change.label.text': 'النص',
'assistant.change.label.title': 'العنوان',
'assistant.change.label.content': 'المحتوى',
'assistant.change.label.icon': 'الأيقونة', 'assistant.change.label.icon': 'الأيقونة',
'assistant.change.label.color': 'اللون', 'assistant.change.label.color': 'اللون',
'assistant.change.label.position': 'الترتيب', 'assistant.change.label.position': 'الترتيب',

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