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