mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-27 17:13:01 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d880cb881 | |||
| 3d076704f9 | |||
| 1b0f043506 | |||
| c383b11648 | |||
| fed564e7c0 | |||
| 9e216f5ae4 | |||
| 0f0232491b | |||
| 35fb59e64e | |||
| ee3fff1dab | |||
| 3bc75d2b1c | |||
| 094426debb | |||
| 313198ba63 | |||
| e677d58f91 | |||
| bc60aea839 | |||
| b5cd951bd2 | |||
| fd818050bd | |||
| 0313597b10 | |||
| 2c867673bf | |||
| 39ebf0976c | |||
| 1ecc449f3e | |||
| b8990782cc | |||
| 8ba7add541 | |||
| a3664e7117 | |||
| 64ed2175a5 | |||
| 447ae54b93 | |||
| 0067b0ff19 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"src": {
|
"src": {
|
||||||
"@typescript-eslint/no-non-null-assertion": 382,
|
"@typescript-eslint/no-non-null-assertion": 382,
|
||||||
"@typescript-eslint/no-misused-promises": 849,
|
"@typescript-eslint/no-misused-promises": 848,
|
||||||
"no-console": 88,
|
"no-console": 88,
|
||||||
"complexity": 220,
|
"complexity": 220,
|
||||||
"@typescript-eslint/no-explicit-any": 146,
|
"@typescript-eslint/no-explicit-any": 146,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"admin.ts": 126,
|
"admin.ts": 112,
|
||||||
"atlas.ts": 9,
|
"atlas.ts": 9,
|
||||||
"backup.ts": 10,
|
"backup.ts": 10,
|
||||||
"budget.ts": 36,
|
"budget.ts": 36,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"components/Admin/AdminPluginsPanel.tsx": 2,
|
"components/Admin/plugins/pluginMenu.ts": 2,
|
||||||
"components/Budget/BudgetPanelMemberChips.tsx": 6,
|
"components/Budget/BudgetPanelMemberChips.tsx": 6,
|
||||||
"components/Budget/CostsPanel.tsx": 3,
|
"components/Budget/CostsPanel.tsx": 3,
|
||||||
"components/Budget/ReceiptPreviewModal.tsx": 2,
|
"components/Budget/ReceiptPreviewModal.tsx": 2,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"e2e/help/fixtures.ts": 2291,
|
"e2e/help/fixtures.ts": 2291,
|
||||||
"src/api/client.ts": 1873,
|
"src/api/client.ts": 1873,
|
||||||
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
|
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
|
||||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
|
||||||
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
||||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||||
"src/components/Map/MapView.tsx": 1385,
|
"src/components/Map/MapView.tsx": 1385,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"App.tsx": 11,
|
"App.tsx": 11,
|
||||||
"components/Admin/AdminPluginsPanel.tsx": 2,
|
|
||||||
"components/Admin/DefaultUserSettingsTab.tsx": 1,
|
"components/Admin/DefaultUserSettingsTab.tsx": 1,
|
||||||
|
"components/Admin/plugins/pluginMenu.ts": 2,
|
||||||
"components/Atlas/AtlasCountryPlaces.tsx": 6,
|
"components/Atlas/AtlasCountryPlaces.tsx": 6,
|
||||||
"components/BackgroundTasks/BackgroundTasksWidget.tsx": 4,
|
"components/BackgroundTasks/BackgroundTasksWidget.tsx": 4,
|
||||||
"components/Budget/BudgetPanel.helpers.ts": 2,
|
"components/Budget/BudgetPanel.helpers.ts": 2,
|
||||||
|
|||||||
@@ -692,6 +692,7 @@ describe('client > request payloads', () => {
|
|||||||
it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => {
|
it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => {
|
||||||
expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({})
|
expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({})
|
||||||
expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true })
|
expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true })
|
||||||
|
expect((await traceOne(() => adminApi.pluginActivate('koffi', false, true))).body).toEqual({ acceptTrekRange: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => {
|
it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => {
|
||||||
|
|||||||
@@ -574,9 +574,9 @@ export const adminApi = {
|
|||||||
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
|
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
|
||||||
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
|
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
|
||||||
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
|
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
|
||||||
pluginInstall: (id: string, opts?: Pick<PluginInstallRequest, 'version' | 'constraint' | 'withDependencies'>) =>
|
pluginInstall: (id: string, opts?: Omit<PluginInstallRequest, 'id'>) =>
|
||||||
apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data),
|
apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data),
|
||||||
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
|
pluginActivate: (id: string, consent?: boolean, accept?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, { consent: consent || undefined, acceptTrekRange: accept || undefined }).then(r => r.data),
|
||||||
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
||||||
// `version` pins the exact version to install (the rollback path); omitted, the server
|
// `version` pins the exact version to install (the rollback path); omitted, the server
|
||||||
// resolves the newest TREK-compatible version itself.
|
// resolves the newest TREK-compatible version itself.
|
||||||
|
|||||||
@@ -489,10 +489,10 @@ describe('AdminPluginsPanel — TREK-version compatibility', () => {
|
|||||||
fireEvent.click(await screen.findByText('Discover'))
|
fireEvent.click(await screen.findByText('Discover'))
|
||||||
}
|
}
|
||||||
|
|
||||||
it('blocks Install when no published version runs on this TREK, and says why', async () => {
|
it('marks a plugin no published version runs on, and still lets the admin install it', async () => {
|
||||||
await openDiscover({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
|
await openDiscover({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
|
||||||
const btn = await screen.findByRole('button', { name: /^incompatible$/i })
|
expect(await screen.findByText('Not confirmed for TREK 3.3.0 yet')).toBeInTheDocument()
|
||||||
expect(btn).toBeDisabled()
|
expect(screen.getByRole('button', { name: /^install$/i })).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('offers the newest version that DOES run here rather than a dead button', async () => {
|
it('offers the newest version that DOES run here rather than a dead button', async () => {
|
||||||
@@ -549,9 +549,11 @@ function panelRoot(container: HTMLElement): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Opens one of the toolbar dropdowns (identified by its current title) and picks an option. */
|
/** Opens one of the toolbar dropdowns (identified by its current title) and picks an option. */
|
||||||
async function pickFilter(trigger: RegExp, option: RegExp) {
|
/** The toolbar's one Filter button; a pick in its menu leaves the menu open. */
|
||||||
fireEvent.click(await screen.findByTitle(trigger))
|
const filterButton = (): HTMLElement => screen.getByRole('button', { name: /^Filter/ })
|
||||||
fireEvent.click(await screen.findByRole('button', { name: option }))
|
async function pickFilter(option: RegExp) {
|
||||||
|
if ((await screen.findByRole('button', { name: /^Filter/ })).getAttribute('aria-expanded') !== 'true') fireEvent.click(filterButton())
|
||||||
|
fireEvent.click(await screen.findByRole('menuitemradio', { name: option }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The enable toggle of a named installed row. */
|
/** The enable toggle of a named installed row. */
|
||||||
@@ -610,9 +612,9 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
|
|||||||
render(<AdminPluginsPanel />)
|
render(<AdminPluginsPanel />)
|
||||||
await screen.findByText('Gotify')
|
await screen.findByText('Gotify')
|
||||||
|
|
||||||
await pickFilter(/^Type: All types$/, /^Widget$/)
|
await pickFilter(/^Widget$/)
|
||||||
|
|
||||||
expect(await screen.findByTitle('Type: Widget')).toBeInTheDocument()
|
expect(await screen.findByRole('menuitemradio', { name: 'Widget' })).toHaveAttribute('aria-checked', 'true')
|
||||||
expect(screen.getByText('Countdown')).toBeInTheDocument()
|
expect(screen.getByText('Countdown')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('Gotify')).not.toBeInTheDocument()
|
expect(screen.queryByText('Gotify')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -626,15 +628,15 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
|
|||||||
render(<AdminPluginsPanel />)
|
render(<AdminPluginsPanel />)
|
||||||
await screen.findByText('OnOne')
|
await screen.findByText('OnOne')
|
||||||
|
|
||||||
await pickFilter(/^Status: All$/, /^Off$/)
|
await pickFilter(/^Off$/)
|
||||||
expect(await screen.findByText('OffOne')).toBeInTheDocument()
|
expect(await screen.findByText('OffOne')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('OnOne')).not.toBeInTheDocument()
|
expect(screen.queryByText('OnOne')).not.toBeInTheDocument()
|
||||||
|
|
||||||
await pickFilter(/^Status: Off$/, /^Error$/)
|
await pickFilter(/^Error$/)
|
||||||
expect(await screen.findByText('BadOne')).toBeInTheDocument()
|
expect(await screen.findByText('BadOne')).toBeInTheDocument()
|
||||||
expect(screen.getByText('boom')).toBeInTheDocument()
|
expect(screen.getByText('boom')).toBeInTheDocument()
|
||||||
|
|
||||||
await pickFilter(/^Status: Error$/, /^Update available$/)
|
await pickFilter(/^Update available$/)
|
||||||
expect(await screen.findByText('OnOne')).toBeInTheDocument()
|
expect(await screen.findByText('OnOne')).toBeInTheDocument()
|
||||||
expect(screen.queryByText('BadOne')).not.toBeInTheDocument()
|
expect(screen.queryByText('BadOne')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -644,11 +646,10 @@ describe('AdminPluginsPanel — load states and toolbar', () => {
|
|||||||
render(<AdminPluginsPanel />)
|
render(<AdminPluginsPanel />)
|
||||||
await screen.findByText('Gotify')
|
await screen.findByText('Gotify')
|
||||||
|
|
||||||
await pickFilter(/^Sort: Name$/, /^Updates first$/)
|
await pickFilter(/^Updates first$/)
|
||||||
expect(await screen.findByTitle('Sort: Updates first')).toBeInTheDocument()
|
expect(filterButton()).toHaveTextContent('Updates first')
|
||||||
|
|
||||||
await clickDiscover()
|
await clickDiscover()
|
||||||
await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument())
|
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-PLUGINS-PANEL-008: rescan re-pulls the registry past its cache and confirms with a toast', async () => {
|
it('FE-COMP-PLUGINS-PANEL-008: rescan re-pulls the registry past its cache and confirms with a toast', async () => {
|
||||||
@@ -1663,15 +1664,16 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('AdminPluginsPanel — security footer', () => {
|
describe('AdminPluginsPanel — security dialog', () => {
|
||||||
it('FE-COMP-PLUGINS-PANEL-048: the containment note expands into its sections', async () => {
|
it('FE-COMP-PLUGINS-PANEL-048: the shield in the header opens how plugins are contained', async () => {
|
||||||
panelWith([])
|
panelWith([])
|
||||||
render(<AdminPluginsPanel />)
|
render(<AdminPluginsPanel />)
|
||||||
await screen.findByText('No plugins installed yet.')
|
await screen.findByText('No plugins installed yet.')
|
||||||
|
|
||||||
expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument()
|
expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByText('How plugins are contained — and the limits'))
|
fireEvent.click(screen.getByRole('button', { name: 'How plugins are secured' }))
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog', { name: 'How plugins are secured' })).toBeInTheDocument()
|
||||||
expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument()
|
expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument()
|
||||||
expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument()
|
expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument()
|
||||||
expect(screen.getByText('What "Signed" means')).toBeInTheDocument()
|
expect(screen.getByText('What "Signed" means')).toBeInTheDocument()
|
||||||
@@ -1700,9 +1702,9 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
|||||||
row({ id: 'c-page', name: 'Gamma Page', description: 'a page', type: 'page', enabled: 1, status: 'error', last_error: 'boom' }),
|
row({ id: 'c-page', name: 'Gamma Page', description: 'a page', type: 'page', enabled: 1, status: 'error', last_error: 'boom' }),
|
||||||
]
|
]
|
||||||
|
|
||||||
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, filter: RegExp, option: RegExp) => {
|
const pickFilter = async (user: ReturnType<typeof userEvent.setup>, option: RegExp) => {
|
||||||
await user.click(screen.getByTitle(filter))
|
if (filterButton().getAttribute('aria-expanded') !== 'true') await user.click(filterButton())
|
||||||
await user.click(screen.getByRole('button', { name: option }))
|
await user.click(screen.getByRole('menuitemradio', { name: option }))
|
||||||
}
|
}
|
||||||
|
|
||||||
it('FE-W5PLG-001: the search box narrows the installed list by name and description', async () => {
|
it('FE-W5PLG-001: the search box narrows the installed list by name and description', async () => {
|
||||||
@@ -1734,7 +1736,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
|||||||
render(<AdminPluginsPanel />)
|
render(<AdminPluginsPanel />)
|
||||||
await screen.findByText('Alpha Widget')
|
await screen.findByText('Alpha Widget')
|
||||||
|
|
||||||
await pickFilter(user, /^Type:/, /^Widget$/)
|
await pickFilter(user, /^Widget$/)
|
||||||
|
|
||||||
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
|
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
|
||||||
expect(screen.getByText('Alpha Widget')).toBeInTheDocument()
|
expect(screen.getByText('Alpha Widget')).toBeInTheDocument()
|
||||||
@@ -1746,15 +1748,15 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
|||||||
render(<AdminPluginsPanel />)
|
render(<AdminPluginsPanel />)
|
||||||
await screen.findByText('Alpha Widget')
|
await screen.findByText('Alpha Widget')
|
||||||
|
|
||||||
await pickFilter(user, /^Status:/, /^Active$/)
|
await pickFilter(user, /^Active$/)
|
||||||
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
|
await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument())
|
||||||
expect(screen.queryByText('Gamma Page')).not.toBeInTheDocument()
|
expect(screen.queryByText('Gamma Page')).not.toBeInTheDocument()
|
||||||
|
|
||||||
await pickFilter(user, /^Status:/, /^Off$/)
|
await pickFilter(user, /^Off$/)
|
||||||
await waitFor(() => expect(screen.getByText('Beta Bridge')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('Beta Bridge')).toBeInTheDocument())
|
||||||
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
|
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
|
||||||
|
|
||||||
await pickFilter(user, /^Status:/, /^Error$/)
|
await pickFilter(user, /^Error$/)
|
||||||
await waitFor(() => expect(screen.getByText('Gamma Page')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('Gamma Page')).toBeInTheDocument())
|
||||||
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
|
expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -1766,13 +1768,13 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
|||||||
await screen.findByText('Alpha Widget')
|
await screen.findByText('Alpha Widget')
|
||||||
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument())
|
||||||
|
|
||||||
await pickFilter(user, /^Sort:/, /^Updates first$/)
|
await pickFilter(user, /^Updates first$/)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map(e => e.textContent)
|
const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map(e => e.textContent)
|
||||||
expect(names[0]).toBe('Gamma Page')
|
expect(names[0]).toBe('Gamma Page')
|
||||||
})
|
})
|
||||||
|
|
||||||
await pickFilter(user, /^Status:/, /^Update available$/)
|
await pickFilter(user, /^Update available$/)
|
||||||
await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument())
|
await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument())
|
||||||
expect(screen.getByText('Gamma Page')).toBeInTheDocument()
|
expect(screen.getByText('Gamma Page')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -1783,11 +1785,10 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
|||||||
render(<AdminPluginsPanel />)
|
render(<AdminPluginsPanel />)
|
||||||
await screen.findByText('Alpha Widget')
|
await screen.findByText('Alpha Widget')
|
||||||
|
|
||||||
await pickFilter(user, /^Sort:/, /^Updates first$/)
|
await pickFilter(user, /^Updates first$/)
|
||||||
expect(screen.getByTitle(/^Sort: Updates first$/)).toBeInTheDocument()
|
expect(filterButton()).toHaveTextContent('Updates first')
|
||||||
|
|
||||||
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
||||||
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
|
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-W5PLG-007: the registry sorts by downloads and by review date', async () => {
|
it('FE-W5PLG-007: the registry sorts by downloads and by review date', async () => {
|
||||||
@@ -1802,19 +1803,19 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
|||||||
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
||||||
await screen.findByText('Old Tool')
|
await screen.findByText('Old Tool')
|
||||||
|
|
||||||
await pickFilter(user, /^Sort:/, /^Most downloads$/)
|
await pickFilter(user, /^Most downloads$/)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
||||||
expect(names[0]).toBe('New Tool')
|
expect(names[0]).toBe('New Tool')
|
||||||
})
|
})
|
||||||
|
|
||||||
await pickFilter(user, /^Sort:/, /^Recently updated$/)
|
await pickFilter(user, /^Recently updated$/)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
||||||
expect(names[0]).toBe('New Tool')
|
expect(names[0]).toBe('New Tool')
|
||||||
})
|
})
|
||||||
|
|
||||||
await pickFilter(user, /^Sort:/, /^Name$/)
|
await pickFilter(user, /^Name$/)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent)
|
||||||
expect(names[0]).toBe('New Tool')
|
expect(names[0]).toBe('New Tool')
|
||||||
@@ -1833,7 +1834,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
|||||||
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
await user.click(screen.getByRole('tab', { name: /Discover/ }))
|
||||||
await screen.findByText('Mapper')
|
await screen.findByText('Mapper')
|
||||||
|
|
||||||
await pickFilter(user, /^Type:/, /^Page$/)
|
await pickFilter(user, /^Page$/)
|
||||||
await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument())
|
await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument())
|
||||||
expect(screen.getByText('Poster')).toBeInTheDocument()
|
expect(screen.getByText('Poster')).toBeInTheDocument()
|
||||||
|
|
||||||
@@ -1962,12 +1963,12 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
|||||||
const { container } = render(<AdminPluginsPanel />)
|
const { container } = render(<AdminPluginsPanel />)
|
||||||
await screen.findByText('Alpha Widget')
|
await screen.findByText('Alpha Widget')
|
||||||
|
|
||||||
await user.click(screen.getByTitle(/^Type:/))
|
await user.click(filterButton())
|
||||||
expect(screen.getByRole('button', { name: /^Widget$/ })).toBeInTheDocument()
|
expect(screen.getByRole('menuitemradio', { name: /^Widget$/ })).toBeInTheDocument()
|
||||||
|
|
||||||
const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement
|
const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement
|
||||||
fireEvent.click(layer)
|
fireEvent.click(layer)
|
||||||
await waitFor(() => expect(screen.queryByRole('button', { name: /^Widget$/ })).not.toBeInTheDocument())
|
await waitFor(() => expect(screen.queryByRole('menuitemradio', { name: /^Widget$/ })).not.toBeInTheDocument())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2004,12 +2005,11 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
|
|||||||
await screen.findByText('Alpha Widget')
|
await screen.findByText('Alpha Widget')
|
||||||
|
|
||||||
await openDiscover(user)
|
await openDiscover(user)
|
||||||
await user.click(screen.getByTitle(/^Sort:/))
|
await user.click(filterButton())
|
||||||
await user.click(screen.getByRole('button', { name: /^Most downloads$/ }))
|
await user.click(screen.getByRole('menuitemradio', { name: /^Most downloads$/ }))
|
||||||
expect(screen.getByTitle(/^Sort: Most downloads$/)).toBeInTheDocument()
|
expect(filterButton()).toHaveTextContent('Most downloads')
|
||||||
|
|
||||||
await user.click(screen.getByRole('tab', { name: /Installed/ }))
|
await user.click(screen.getByRole('tab', { name: /Installed/ }))
|
||||||
await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument())
|
await waitFor(() => expect(filterButton()).toHaveTextContent(/Name$/))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-W5PLG-017: an outrun release offers the newest compatible version instead of a dead button', async () => {
|
it('FE-W5PLG-017: an outrun release offers the newest compatible version instead of a dead button', async () => {
|
||||||
@@ -2026,9 +2026,8 @@ describe('AdminPluginsPanel registry cards and dependency chips', () => {
|
|||||||
expect(offer).not.toBeDisabled()
|
expect(offer).not.toBeDisabled()
|
||||||
expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0'))
|
expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0'))
|
||||||
|
|
||||||
const dead = screen.getByRole('button', { name: /^Incompatible$/ })
|
// Nothing fits: no dead button any more, but a card that says so before anything installs.
|
||||||
expect(dead).toBeDisabled()
|
expect(screen.getByText(/^Not confirmed for TREK .+ yet$/)).toBeInTheDocument()
|
||||||
expect(dead.getAttribute('title')).toBeTruthy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-W5PLG-018: an already installed registry entry cannot be installed again', async () => {
|
it('FE-W5PLG-018: an already installed registry entry cannot be installed again', async () => {
|
||||||
@@ -2556,7 +2555,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
|
|||||||
expect(await screen.findByText('Version checks off')).toBeInTheDocument()
|
expect(await screen.findByText('Version checks off')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => {
|
it('FE-PLG-BYPASS-002: offers Install for an incompatible entry and asks before installing', async () => {
|
||||||
let posted: unknown = null
|
let posted: unknown = null
|
||||||
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
|
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
|
||||||
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
||||||
@@ -2566,7 +2565,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
|
|||||||
withToast()
|
withToast()
|
||||||
fireEvent.click(await screen.findByText('Discover'))
|
fireEvent.click(await screen.findByText('Discover'))
|
||||||
|
|
||||||
const btn = await screen.findByRole('button', { name: /^install anyway$/i })
|
const btn = await screen.findByRole('button', { name: /^install$/i })
|
||||||
expect(btn).toBeEnabled()
|
expect(btn).toBeEnabled()
|
||||||
fireEvent.click(btn)
|
fireEvent.click(btn)
|
||||||
|
|
||||||
@@ -2585,7 +2584,7 @@ describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANG
|
|||||||
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
|
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
|
||||||
withToast()
|
withToast()
|
||||||
fireEvent.click(await screen.findByText('Discover'))
|
fireEvent.click(await screen.findByText('Discover'))
|
||||||
fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }))
|
fireEvent.click(await screen.findByRole('button', { name: /^install$/i }))
|
||||||
|
|
||||||
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
|
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
|
||||||
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
|
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
|
||||||
import {
|
import {
|
||||||
Blocks, AlertTriangle, RefreshCw, Trash2, Download, Bug, ShieldCheck, UploadCloud,
|
Blocks, AlertTriangle, RefreshCw, Trash2, Download, Bug, ShieldCheck, UploadCloud,
|
||||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
|
ArrowUpCircle, Github, ExternalLink, Check, Search, Link2, KeyRound, ShieldAlert,
|
||||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database,
|
SlidersHorizontal, CircleDot, Database,
|
||||||
Globe, Info, History, PauseCircle, Puzzle, Bot, Loader2,
|
Globe, History, Puzzle, Bot, Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import PluginIcon from '../shared/PluginIcon'
|
import PluginIcon from '../shared/PluginIcon'
|
||||||
import { useInstanceSettings } from './useInstanceSettings'
|
import { useInstanceSettings } from './useInstanceSettings'
|
||||||
@@ -23,15 +22,17 @@ import {
|
|||||||
SettingsCard, SettingRows, SettingRow, SettingsHint, StatusPill,
|
SettingsCard, SettingRows, SettingRow, SettingsHint, StatusPill,
|
||||||
SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON,
|
SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON,
|
||||||
} from '../Settings/settingsKit'
|
} from '../Settings/settingsKit'
|
||||||
import { POPOVER } from '../Packing/packingPopoverStyles'
|
|
||||||
import {
|
import {
|
||||||
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, deriveDeps, fingerprint, formatCompactCount,
|
KNOWN_TYPES, PERM_KEYS, blockIsCurrent, dependencyRows, fingerprint, formatCompactCount,
|
||||||
installOffer, isRegistrySourced, parseJson, signatureBlockView, sortLabel, statusLabel,
|
installOffer, signatureBlockView,
|
||||||
type PluginDep, type PluginRow, type RegistryItem, type SigSubject, type SortKey,
|
type PluginDep, type PluginRow, type RegistryItem, type SigSubject,
|
||||||
type StatusFilter, type T, type TypeFilter, type VersionInfo, type VersionMismatch,
|
type T, type VersionInfo, type VersionMismatch,
|
||||||
} from './plugins/pluginModel'
|
} from './plugins/pluginModel'
|
||||||
import { usePluginDetail } from './plugins/usePluginDetail'
|
import { usePluginDetail } from './plugins/usePluginDetail'
|
||||||
import { usePluginsAdmin } from './plugins/usePluginsAdmin'
|
import { usePluginsAdmin } from './plugins/usePluginsAdmin'
|
||||||
|
import PluginFilterMenu from './plugins/PluginFilterMenu'
|
||||||
|
import PluginInstalledRow from './plugins/PluginInstalledRow'
|
||||||
|
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
|
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
|
||||||
@@ -42,31 +43,13 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
|
|||||||
* tile; the security section at the bottom explains the model honestly.
|
* tile; the security section at the bottom explains the model honestly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Runtime health → dot colour on the icon tile.
|
|
||||||
const HEALTH: Record<string, string> = {
|
|
||||||
active: 'bg-success',
|
|
||||||
starting: 'bg-info animate-pulse',
|
|
||||||
error: 'bg-danger',
|
|
||||||
inactive: 'bg-content-faint/60',
|
|
||||||
disabled: 'bg-warning',
|
|
||||||
incompatible: 'bg-warning',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The head band of a dialog that warns before something happens: a faint wash of the warning tone. */
|
/** The head band of a dialog that warns before something happens: a faint wash of the warning tone. */
|
||||||
const WARN_TINT = 'color-mix(in srgb, var(--warning) 10%, transparent)'
|
const WARN_TINT = 'color-mix(in srgb, var(--warning) 10%, transparent)'
|
||||||
/** The eyebrow over a block, the size the dialogs' sections use. */
|
/** The eyebrow over a block, the size the dialogs' sections use. */
|
||||||
const EYEBROW = 'm-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
const EYEBROW = 'm-0 font-geist font-bold uppercase tracking-[.08em] text-content-faint'
|
||||||
/** A capability or dependency chip on an installed row. */
|
|
||||||
const CHIP = 'inline-flex max-w-full items-center gap-1.5 rounded-full border px-2 py-[2px] font-medium'
|
|
||||||
const CHIP_NEUTRAL = 'border-edge-faint bg-surface-secondary text-content-secondary'
|
|
||||||
const CHIP_NET = 'border-transparent bg-info-soft text-info'
|
|
||||||
const CHIP_WARN = 'border-transparent bg-warning-soft text-warning'
|
|
||||||
/** A small action inside a row or a card: the kit's button, a size down. */
|
/** A small action inside a row or a card: the kit's button, a size down. */
|
||||||
const SMALL_BUTTON = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-semibold text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
|
const SMALL_BUTTON = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 py-1.5 font-semibold text-content shadow-sm ring-1 ring-edge-faint hover:bg-surface-secondary disabled:cursor-default disabled:opacity-50'
|
||||||
const SMALL_PRIMARY = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 py-1.5 font-semibold text-accent-text hover:opacity-90 disabled:cursor-default disabled:bg-surface-tertiary disabled:text-content-faint'
|
const SMALL_PRIMARY = 'inline-flex flex-none items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 py-1.5 font-semibold text-accent-text hover:opacity-90 disabled:cursor-default disabled:bg-surface-tertiary disabled:text-content-faint'
|
||||||
/** A line of state under a row's name: a refused update, a paused one, an error. */
|
|
||||||
const ROW_NOTE = 'mt-1.5 flex min-w-0 items-center gap-1.5'
|
|
||||||
|
|
||||||
/** A pill that explains itself on hover. */
|
/** A pill that explains itself on hover. */
|
||||||
function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
|
function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -76,19 +59,6 @@ function HintPill({ hint, children }: { hint: string; children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReviewedBadge({ t }: { t: T }) {
|
|
||||||
return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */
|
|
||||||
function SideloadedBadge({ t }: { t: T }) {
|
|
||||||
return (
|
|
||||||
<HintPill hint={t('admin.plugins.sideloadedHint')}>
|
|
||||||
<StatusPill tone="warning" icon={<UploadCloud size={11} />}>{t('admin.plugins.sideloaded')}</StatusPill>
|
|
||||||
</HintPill>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Signed / Unsigned, for registry plugins only (see isRegistrySourced).
|
* Signed / Unsigned, for registry plugins only (see isRegistrySourced).
|
||||||
*
|
*
|
||||||
@@ -113,15 +83,6 @@ function TrustBadge({ signed, t }: { signed: boolean; t: T }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Marks a dev-linked plugin: loaded from a local build dir + hot-reloaded (dev only). */
|
|
||||||
function DevLinkBadge({ t }: { t: T }) {
|
|
||||||
return (
|
|
||||||
<HintPill hint={t('admin.plugins.devLinkHint')}>
|
|
||||||
<StatusPill icon={<Link2 size={11} />}>{t('admin.plugins.devLinkBadge')}</StatusPill>
|
|
||||||
</HintPill>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TypeBadge({ type, t }: { type: string; t: T }) {
|
function TypeBadge({ type, t }: { type: string; t: T }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex flex-none items-center rounded-full bg-surface-tertiary px-2 py-[3px] font-geist font-bold uppercase tracking-[.06em] text-content-muted" style={fs(9.5)}>
|
<span className="inline-flex flex-none items-center rounded-full bg-surface-tertiary px-2 py-[3px] font-geist font-bold uppercase tracking-[.06em] text-content-muted" style={fs(9.5)}>
|
||||||
@@ -145,6 +106,7 @@ export default function AdminPluginsPanel() {
|
|||||||
sort, statusFilter, toggle, typeFilter, uninstallConfirmed, updatable, updateAll, updateAvailable, uploadInputRef,
|
sort, statusFilter, toggle, typeFilter, uninstallConfirmed, updatable, updateAll, updateAvailable, uploadInputRef,
|
||||||
uploadPlugin, versionPicker, view,
|
uploadPlugin, versionPicker, view,
|
||||||
} = usePluginsAdmin()
|
} = usePluginsAdmin()
|
||||||
|
const [securityOpen, setSecurityOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mb-24 sm:mb-0"
|
<div className="relative mb-24 sm:mb-0"
|
||||||
@@ -181,6 +143,9 @@ export default function AdminPluginsPanel() {
|
|||||||
) : undefined}
|
) : undefined}
|
||||||
action={ready ? (
|
action={ready ? (
|
||||||
<>
|
<>
|
||||||
|
<BandButton label={t('admin.plugins.security.short')} onClick={() => setSecurityOpen(true)}>
|
||||||
|
<ShieldCheck size={15} strokeWidth={2} />
|
||||||
|
</BandButton>
|
||||||
<BandButton label={t('admin.plugins.upload')} onClick={pickUpload} disabled={busy === '__upload'}>
|
<BandButton label={t('admin.plugins.upload')} onClick={pickUpload} disabled={busy === '__upload'}>
|
||||||
<UploadCloud size={15} strokeWidth={2} />
|
<UploadCloud size={15} strokeWidth={2} />
|
||||||
</BandButton>
|
</BandButton>
|
||||||
@@ -242,31 +207,9 @@ export default function AdminPluginsPanel() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<FilterMenu id="type" label={t('admin.plugins.filterType')} value={typeFilter} menu={menu} setMenu={setMenu} icon={<SlidersHorizontal size={14} />}
|
<PluginFilterMenu t={t} view={view} typeFilter={typeFilter} setTypeFilter={setTypeFilter}
|
||||||
options={[
|
statusFilter={statusFilter} setStatusFilter={setStatusFilter} sort={sort} setSort={setSort}
|
||||||
['all', t('admin.plugins.allTypes')], ['widget', t('admin.plugins.type.widget')],
|
menu={menu} setMenu={setMenu} />
|
||||||
['integration', t('admin.plugins.type.integration')], ['page', t('admin.plugins.type.page')],
|
|
||||||
['trip-page', t('admin.plugins.type.trip-page')],
|
|
||||||
]}
|
|
||||||
valueLabel={typeFilter === 'all' ? t('admin.plugins.allTypes') : t(`admin.plugins.type.${typeFilter}` as never)}
|
|
||||||
onPick={v => setTypeFilter(v as TypeFilter)} />
|
|
||||||
|
|
||||||
{view === 'installed' && (
|
|
||||||
<FilterMenu id="status" label={t('admin.plugins.filterStatus')} value={statusFilter} menu={menu} setMenu={setMenu} icon={<CircleDot size={14} />}
|
|
||||||
options={[
|
|
||||||
['all', t('admin.plugins.allStatuses')], ['on', t('admin.plugins.status.active')], ['off', t('admin.plugins.stateOff')],
|
|
||||||
['update', t('admin.plugins.filterUpdate')], ['err', t('admin.plugins.status.error')],
|
|
||||||
]}
|
|
||||||
valueLabel={statusLabel(statusFilter, t)}
|
|
||||||
onPick={v => setStatusFilter(v as StatusFilter)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FilterMenu id="sort" label={t('admin.plugins.sortBy')} value={sort} menu={menu} setMenu={setMenu} icon={<ArrowUpDown size={14} />}
|
|
||||||
options={view === 'discover'
|
|
||||||
? [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['downloads', t('admin.plugins.sortDownloads')]]
|
|
||||||
: [['name', t('admin.plugins.sortName')], ['recent', t('admin.plugins.sortRecent')], ['updates', t('admin.plugins.sortUpdates')]]}
|
|
||||||
valueLabel={sortLabel(sort, t)}
|
|
||||||
onPick={v => setSort(v as SortKey)} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === 'discover' ? (
|
{view === 'discover' ? (
|
||||||
@@ -299,7 +242,7 @@ export default function AdminPluginsPanel() {
|
|||||||
) : (
|
) : (
|
||||||
<SettingRows>
|
<SettingRows>
|
||||||
{shownInstalled.map(p => (
|
{shownInstalled.map(p => (
|
||||||
<InstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
|
<PluginInstalledRow key={p.id} p={p} t={t} busy={busy} menu={menu} setMenu={setMenu}
|
||||||
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
|
hasUpdate={updateAvailable(p)} latestVer={latest[p.id]}
|
||||||
newerIncompatible={newerIncompatible(p)}
|
newerIncompatible={newerIncompatible(p)}
|
||||||
blocked={blockIsCurrent(p, latest[p.id])}
|
blocked={blockIsCurrent(p, latest[p.id])}
|
||||||
@@ -320,7 +263,7 @@ export default function AdminPluginsPanel() {
|
|||||||
)}
|
)}
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
<SecurityInfo t={t} />
|
{securityOpen && <PluginSecurityDialog t={t} onClose={() => setSecurityOpen(false)} />}
|
||||||
|
|
||||||
{/* Registry detail dialog */}
|
{/* Registry detail dialog */}
|
||||||
{detailFor && (
|
{detailFor && (
|
||||||
@@ -438,268 +381,6 @@ function SegBtn({ active, onClick, label, count }: { active: boolean; onClick: (
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */
|
|
||||||
function useFollowTrigger(open: boolean) {
|
|
||||||
const [, reanchor] = useState(0)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
const onMove = () => reanchor(n => n + 1)
|
|
||||||
window.addEventListener('scroll', onMove, true)
|
|
||||||
window.addEventListener('resize', onMove)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('scroll', onMove, true)
|
|
||||||
window.removeEventListener('resize', onMove)
|
|
||||||
}
|
|
||||||
}, [open])
|
|
||||||
}
|
|
||||||
|
|
||||||
function FilterMenu({ id, label, valueLabel, options, onPick, value, menu, setMenu, icon }: {
|
|
||||||
id: string; label: string; valueLabel: string
|
|
||||||
options: Array<[string, string]>; onPick: (v: string) => void; value: string
|
|
||||||
menu: string | null; setMenu: (v: string | null) => void; icon?: ReactNode
|
|
||||||
}) {
|
|
||||||
const open = menu === id
|
|
||||||
const active = value !== options[0]?.[0]
|
|
||||||
const btnRef = useRef<HTMLButtonElement>(null)
|
|
||||||
// Portaled like the row menu: the card around the toolbar clips what overflows it.
|
|
||||||
useFollowTrigger(open)
|
|
||||||
return (
|
|
||||||
<div className="relative flex-none">
|
|
||||||
<button type="button" ref={btnRef} onClick={() => setMenu(open ? null : id)} title={`${label}: ${valueLabel}`} aria-expanded={open}
|
|
||||||
className={`inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
|
|
||||||
active ? 'border-[color:var(--text-primary)]' : 'border-edge'}`}
|
|
||||||
style={fs(12.5, 'body')}>
|
|
||||||
<span className="flex-none text-content-faint">{icon}</span>
|
|
||||||
<span>{label}: </span>
|
|
||||||
<span className="font-semibold text-content">{valueLabel}</span>
|
|
||||||
<ChevronDown size={13} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
|
|
||||||
</button>
|
|
||||||
{open && createPortal(
|
|
||||||
<div className="min-w-[200px] max-w-[calc(100vw-2rem)]" style={{ ...POPOVER, ...anchorMenu(btnRef.current) }}>
|
|
||||||
{options.map(([v, lbl]) => (
|
|
||||||
<button type="button" key={v} onClick={() => { onPick(v); setMenu(null) }}
|
|
||||||
className={`${MENU_ITEM} ${value === v ? 'font-semibold text-content' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
|
|
||||||
<span className="min-w-0 flex-1 truncate text-start">{lbl}</span>
|
|
||||||
<Check size={14} className={`flex-none text-content-muted ${value === v ? 'opacity-100' : 'opacity-0'}`} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: {
|
|
||||||
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
|
|
||||||
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
|
|
||||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
|
|
||||||
onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void
|
|
||||||
}) {
|
|
||||||
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
|
|
||||||
const deps = deriveDeps(p, t)
|
|
||||||
const menuOpen = menu === `row:${p.id}`
|
|
||||||
const menuBtnRef = useRef<HTMLButtonElement>(null)
|
|
||||||
const working = busy === p.id
|
|
||||||
const on = p.enabled === 1 && p.status !== 'error'
|
|
||||||
|
|
||||||
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
|
|
||||||
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
|
|
||||||
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
|
|
||||||
// Re-anchor while the page moves under it.
|
|
||||||
useFollowTrigger(menuOpen)
|
|
||||||
|
|
||||||
const hostCount = p.egressHostCount ?? 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`group relative flex items-start gap-3 px-3.5 py-3 transition-colors hover:bg-surface-secondary ${working ? 'opacity-60' : ''}`}>
|
|
||||||
<div className="relative mt-0.5 flex-none">
|
|
||||||
<span className="grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-secondary">
|
|
||||||
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
|
|
||||||
</span>
|
|
||||||
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
|
|
||||||
<span className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`} />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
||||||
<span className="min-w-0 truncate font-semibold tracking-[-.006em] text-content" style={fs(13.5, 'body')}>{p.name}</span>
|
|
||||||
{p.version && <span className="font-geist font-medium tabular-nums text-content-faint" style={fs(11)}>v{p.version}</span>}
|
|
||||||
{p.reviewed_at && <ReviewedBadge t={t} />}
|
|
||||||
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
|
|
||||||
{p.source_repo === 'local:link' && <DevLinkBadge t={t} />}
|
|
||||||
{/* Registry plugins only — a sideloaded/dev-linked plugin already says something
|
|
||||||
strictly stronger, and stacking "Unsigned" on top of it just dilutes the amber. */}
|
|
||||||
{isRegistrySourced(p.source_repo) && <TrustBadge signed={!!p.signed} t={t} />}
|
|
||||||
</div>
|
|
||||||
{p.description && <p className="m-0 mt-0.5 truncate text-content-muted" style={fs(12, 'body')}>{p.description}</p>}
|
|
||||||
{/* A refused update leaves a WORKING plugin pinned at its old version — so this is
|
|
||||||
its own row state, not an error state, and it persists until it is resolved
|
|
||||||
rather than dying with the toast that first reported it. */}
|
|
||||||
{blocked && p.updateBlock && (
|
|
||||||
<div className={`${ROW_NOTE} text-warning`} style={fs(11.5)}>
|
|
||||||
<ShieldAlert size={13} className="flex-none" />
|
|
||||||
<span className="truncate">{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })}</span>
|
|
||||||
<button type="button" onClick={onReviewBlock}
|
|
||||||
className="flex-none font-semibold underline underline-offset-2 hover:opacity-80">
|
|
||||||
{t('admin.plugins.reviewBlock')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* A newer version this TREK can't run — informational, never a button: the fix
|
|
||||||
is a TREK upgrade, so the row must not nag or offer a doomed install. */}
|
|
||||||
{newerIncompatible && (
|
|
||||||
<div className={`${ROW_NOTE} text-content-faint`} style={fs(11.5)}>
|
|
||||||
<Info size={13} className="flex-none" />
|
|
||||||
<span className="truncate">{t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Held: the admin deliberately installed a non-latest version, so updates are
|
|
||||||
paused rather than nagged — resuming is one click, right where the pause shows. */}
|
|
||||||
{p.updateHold && (
|
|
||||||
<div className={`${ROW_NOTE} text-content-faint`} style={fs(11.5)}>
|
|
||||||
<PauseCircle size={13} className="flex-none" />
|
|
||||||
<span className="truncate">{t('admin.plugins.updatesHeld', { version: p.version ?? '' })}</span>
|
|
||||||
<button type="button" onClick={onResume} disabled={working}
|
|
||||||
className="flex-none font-semibold text-content-secondary underline underline-offset-2 hover:opacity-80 disabled:opacity-50">
|
|
||||||
{t('admin.plugins.resumeUpdates')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{p.status === 'error' && p.last_error ? (
|
|
||||||
<div className={`${ROW_NOTE} text-danger`} style={fs(11.5)}>
|
|
||||||
<AlertTriangle size={13} className="flex-none" /><span className="truncate">{p.last_error}</span>
|
|
||||||
</div>
|
|
||||||
) : (caps.length > 0 || p.operatorEgress) && (
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
|
||||||
{caps.map((c, i) => (
|
|
||||||
<span key={i} className={`${CHIP} ${c.net ? CHIP_NET : CHIP_NEUTRAL}`} style={fs(11)}>
|
|
||||||
<c.icon size={12} className={c.net ? 'text-info' : 'text-content-muted'} /><span className="truncate">{c.label}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{/* This plugin talks to a service only the OPERATOR can name (a self-hosted
|
|
||||||
Gotify/ntfy), so its manifest can't list the host — the admin adds it.
|
|
||||||
Actionable, and warning-toned until at least one host exists, because
|
|
||||||
until then the plugin cannot reach anything and looks silently broken. */}
|
|
||||||
{p.operatorEgress && (
|
|
||||||
<Tooltip label={t('admin.plugins.allowedHosts.hint')}>
|
|
||||||
<button type="button" onClick={onEgress}
|
|
||||||
className={`${CHIP} ${hostCount > 0 ? CHIP_NET : CHIP_WARN} hover:opacity-80`} style={fs(11)}>
|
|
||||||
<Globe size={12} />
|
|
||||||
{hostCount > 0
|
|
||||||
? t('admin.plugins.allowedHosts.count', { n: hostCount })
|
|
||||||
: t('admin.plugins.allowedHosts.add')}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{deps.length > 0 && (
|
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
|
||||||
{deps.map((d, i) => (
|
|
||||||
<span key={i} className={`${CHIP} ${d.blocked || d.warn ? CHIP_WARN : CHIP_NEUTRAL}`} style={fs(11)}>
|
|
||||||
<d.icon size={12} className={d.blocked || d.warn ? 'text-warning' : 'text-content-muted'} /><span className="truncate">{d.label}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-none items-center gap-2 self-center">
|
|
||||||
{hasUpdate && (
|
|
||||||
<button type="button" onClick={onUpdate} disabled={working}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-1 font-semibold text-warning hover:opacity-90 disabled:opacity-50"
|
|
||||||
style={fs(11.5)}>
|
|
||||||
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<span className={`min-w-[42px] text-end font-medium ${on ? 'text-content-secondary' : 'text-content-faint'}`} style={fs(12)}>
|
|
||||||
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
|
|
||||||
</span>
|
|
||||||
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={onToggle} />
|
|
||||||
<div className="relative">
|
|
||||||
<Tooltip label={t('admin.table.actions')} disabled={menuOpen}>
|
|
||||||
<button type="button" ref={menuBtnRef} data-testid={`plugin-row-menu-btn-${p.id}`} onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
|
||||||
aria-expanded={menuOpen} aria-label={t('admin.table.actions')}
|
|
||||||
className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}>
|
|
||||||
<MoreHorizontal size={17} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
{menuOpen && createPortal(
|
|
||||||
<div data-testid={`plugin-row-menu-${p.id}`} style={{ ...POPOVER, ...anchorMenu(menuBtnRef.current) }}
|
|
||||||
className="min-w-[200px]">
|
|
||||||
{p.enabled === 1 && (
|
|
||||||
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
|
|
||||||
)}
|
|
||||||
{/* A plugin gets the item if it declares scope:'instance' fields OR actions —
|
|
||||||
an action-only plugin still needs the dialog to run its buttons. */}
|
|
||||||
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
|
|
||||||
<MenuItem icon={<SlidersHorizontal size={14} />} label={t('admin.plugins.instanceSettings')} onClick={onSettings} />
|
|
||||||
)}
|
|
||||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
|
|
||||||
{/* Only a plugin that DECLARED operatorEgress gets the item — an admin must
|
|
||||||
never be invited to widen egress for a plugin that didn't ask for it
|
|
||||||
(same rule the row's egress chip follows). */}
|
|
||||||
{p.operatorEgress && (
|
|
||||||
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
|
|
||||||
)}
|
|
||||||
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
|
|
||||||
{isRegistrySourced(p.source_repo) && (
|
|
||||||
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
|
|
||||||
)}
|
|
||||||
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
|
|
||||||
<>
|
|
||||||
<a href={`https://github.com/${p.source_repo}`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
|
|
||||||
className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}>
|
|
||||||
<span className="flex w-4 flex-none justify-center text-content-muted"><Github size={14} /></span> {t('admin.plugins.sourceRepo')}
|
|
||||||
</a>
|
|
||||||
<a href={`https://github.com/${p.source_repo}/issues`} target="_blank" rel="noreferrer" onClick={() => setMenu(null)}
|
|
||||||
className={`${MENU_ITEM} text-content-secondary`} style={fs(12.5, 'body')}>
|
|
||||||
<span className="flex w-4 flex-none justify-center text-content-muted"><CircleDot size={14} /></span> {t('admin.plugins.reportIssue')}
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="mx-1.5 my-1 h-px bg-edge-faint" />
|
|
||||||
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={onUninstall} />
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tallest the ⋯ menu gets (6 items + divider + padding). Drives the flip. */
|
|
||||||
const ROW_MENU_MAX_H = 260
|
|
||||||
|
|
||||||
/** Pin a portaled menu to the right edge of its trigger, flipping up when the bottom is tight. */
|
|
||||||
function anchorMenu(trigger: HTMLElement | null): CSSProperties {
|
|
||||||
const r = trigger?.getBoundingClientRect()
|
|
||||||
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 }
|
|
||||||
const spaceBelow = window.innerHeight - r.bottom
|
|
||||||
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow
|
|
||||||
return {
|
|
||||||
position: 'fixed',
|
|
||||||
right: Math.max(8, window.innerWidth - r.right),
|
|
||||||
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
|
|
||||||
zIndex: 100,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One entry of a popover menu, in the packing popovers' shape. */
|
|
||||||
const MENU_ITEM = 'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary'
|
|
||||||
|
|
||||||
function MenuItem({ icon, label, onClick, danger }: { icon: ReactNode; label: string; onClick: () => void; danger?: boolean }) {
|
|
||||||
return (
|
|
||||||
<button type="button" onClick={onClick}
|
|
||||||
className={`${MENU_ITEM} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary'}`} style={fs(12.5, 'body')}>
|
|
||||||
<span className={`flex w-4 flex-none justify-center ${danger ? 'text-danger' : 'text-content-muted'}`}>{icon}</span> {label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) {
|
function Screenshot({ url, className, iconSize = 28 }: { url: string | null; className: string; iconSize?: number }) {
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
return (
|
return (
|
||||||
@@ -751,6 +432,16 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
|||||||
<StatusPill icon={<ShieldCheck size={11} className="text-success" />}>{t('admin.plugins.reviewed')}</StatusPill>
|
<StatusPill icon={<ShieldCheck size={11} className="text-success" />}>{t('admin.plugins.reviewed')}</StatusPill>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Says up front that this one has not been confirmed for the TREK it would run on,
|
||||||
|
so "Install" never reads as a plain install; the dialog explains the risk. */}
|
||||||
|
{!installed && offer.warn && (
|
||||||
|
// Solid, not the soft pill "Reviewed" uses: it has to stand out on any screenshot.
|
||||||
|
<span className="absolute start-2.5 top-2.5 inline-flex max-w-[62%] items-center gap-1 rounded-full bg-warning px-2 py-[3px] font-geist font-semibold text-[color:var(--surface-card)] shadow-md"
|
||||||
|
style={fs(11)}>
|
||||||
|
<AlertTriangle size={11} className="flex-none" />
|
||||||
|
<span className="truncate">{t('admin.plugins.untestedChip', { host: offer.warn.hostVersion })}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="absolute -bottom-4 start-3.5 z-[1] grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-card shadow-sm">
|
<span className="absolute -bottom-4 start-3.5 z-[1] grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-card shadow-sm">
|
||||||
<PluginIcon name={item.icon} size={20} className="text-content-secondary" />
|
<PluginIcon name={item.icon} size={20} className="text-content-secondary" />
|
||||||
</span>
|
</span>
|
||||||
@@ -829,8 +520,8 @@ function Tile({ children, warn }: { children: ReactNode; warn?: boolean }) {
|
|||||||
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
|
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
|
||||||
* component rather than ConfirmDialog because the notice mode has no cancel button.
|
* component rather than ConfirmDialog because the notice mode has no cancel button.
|
||||||
*/
|
*/
|
||||||
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
|
function RangeBypassDialog({ copy: { title, body, confirm, confirmLabel }, onConfirm, onClose, t }: {
|
||||||
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T
|
copy: { title: string; body: string; confirm: boolean; confirmLabel: string }; onConfirm: () => void; onClose: () => void; t: T
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogShell
|
<DialogShell
|
||||||
@@ -844,7 +535,7 @@ function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose,
|
|||||||
{confirm ? (
|
{confirm ? (
|
||||||
<>
|
<>
|
||||||
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
|
||||||
<DialogButton variant="primary" onClick={onConfirm}>{t('admin.plugins.installAnyway')}</DialogButton>
|
<DialogButton variant="primary" onClick={onConfirm}>{confirmLabel}</DialogButton>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<DialogButton variant="primary" onClick={onClose}>{t('common.ok')}</DialogButton>
|
<DialogButton variant="primary" onClick={onClose}>{t('common.ok')}</DialogButton>
|
||||||
@@ -1495,45 +1186,3 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
|||||||
</DialogShell>
|
</DialogShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer: a plain-language note on what "Reviewed" means, plus a collapsible
|
|
||||||
// panel that lays out how plugins are contained, the limits, and the worst case.
|
|
||||||
function SecurityInfo({ t }: { t: T }) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const sections: Array<[string, string]> = [
|
|
||||||
['admin.plugins.security.isolationTitle', 'admin.plugins.security.isolationBody'],
|
|
||||||
['admin.plugins.security.permsTitle', 'admin.plugins.security.permsBody'],
|
|
||||||
['admin.plugins.security.limitsTitle', 'admin.plugins.security.limitsBody'],
|
|
||||||
['admin.plugins.security.worstTitle', 'admin.plugins.security.worstBody'],
|
|
||||||
['admin.plugins.security.reviewedTitle', 'admin.plugins.security.reviewedBody'],
|
|
||||||
['admin.plugins.security.signedTitle', 'admin.plugins.security.signedBody'],
|
|
||||||
['admin.plugins.security.trustTitle', 'admin.plugins.security.trustBody'],
|
|
||||||
]
|
|
||||||
return (
|
|
||||||
<SettingsCard
|
|
||||||
icon={Lock}
|
|
||||||
title={(
|
|
||||||
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open}
|
|
||||||
className="inline-flex max-w-full items-center gap-1.5 text-start font-bold text-content hover:opacity-80">
|
|
||||||
<span className="truncate">{t('admin.plugins.security.title')}</span>
|
|
||||||
<ChevronDown size={15} className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<ShieldCheck size={14} className="mt-0.5 flex-none text-content-faint" />
|
|
||||||
<SettingsHint>{t('admin.plugins.reviewedMeaning')}</SettingsHint>
|
|
||||||
</div>
|
|
||||||
{open && (
|
|
||||||
<div className="grid gap-2.5 sm:grid-cols-2">
|
|
||||||
{sections.map(([h, b]) => (
|
|
||||||
<div key={h} className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
|
|
||||||
<h4 className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>{t(h as never)}</h4>
|
|
||||||
<p className="m-0 mt-1 leading-relaxed text-content-muted" style={fs(12, 'body')}>{t(b as never)}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SettingsCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { Check, ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { POPOVER } from '../../Packing/packingPopoverStyles';
|
||||||
|
import { fs } from '../../shared/DialogShell';
|
||||||
|
import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu';
|
||||||
|
import { sortLabel, type SortKey, type StatusFilter, type T, type TypeFilter } from './pluginModel';
|
||||||
|
|
||||||
|
const MENU_ID = 'filters';
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
options: Array<[string, string]>;
|
||||||
|
onPick: (v: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type, status and order of the plugin list behind one button instead of three, so the
|
||||||
|
* toolbar stays search plus one control. The button counts the filters that narrow the
|
||||||
|
* list (the order does not narrow it) and names the order it is in.
|
||||||
|
*/
|
||||||
|
export default function PluginFilterMenu({
|
||||||
|
t,
|
||||||
|
view,
|
||||||
|
typeFilter,
|
||||||
|
setTypeFilter,
|
||||||
|
statusFilter,
|
||||||
|
setStatusFilter,
|
||||||
|
sort,
|
||||||
|
setSort,
|
||||||
|
menu,
|
||||||
|
setMenu,
|
||||||
|
}: {
|
||||||
|
t: T;
|
||||||
|
view: 'installed' | 'discover';
|
||||||
|
typeFilter: TypeFilter;
|
||||||
|
setTypeFilter: (v: TypeFilter) => void;
|
||||||
|
statusFilter: StatusFilter;
|
||||||
|
setStatusFilter: (v: StatusFilter) => void;
|
||||||
|
sort: SortKey;
|
||||||
|
setSort: (v: SortKey) => void;
|
||||||
|
menu: string | null;
|
||||||
|
setMenu: (v: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const open = menu === MENU_ID;
|
||||||
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
useFollowTrigger(open);
|
||||||
|
|
||||||
|
const sections: Section[] = [
|
||||||
|
{
|
||||||
|
title: t('admin.plugins.filterType'),
|
||||||
|
value: typeFilter,
|
||||||
|
options: [
|
||||||
|
['all', t('admin.plugins.allTypes')],
|
||||||
|
['widget', t('admin.plugins.type.widget')],
|
||||||
|
['integration', t('admin.plugins.type.integration')],
|
||||||
|
['page', t('admin.plugins.type.page')],
|
||||||
|
['trip-page', t('admin.plugins.type.trip-page')],
|
||||||
|
],
|
||||||
|
onPick: (v) => setTypeFilter(v as TypeFilter),
|
||||||
|
},
|
||||||
|
...(view === 'installed'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: t('admin.plugins.filterStatus'),
|
||||||
|
value: statusFilter,
|
||||||
|
options: [
|
||||||
|
['all', t('admin.plugins.allStatuses')],
|
||||||
|
['on', t('admin.plugins.status.active')],
|
||||||
|
['off', t('admin.plugins.stateOff')],
|
||||||
|
['update', t('admin.plugins.filterUpdate')],
|
||||||
|
['err', t('admin.plugins.status.error')],
|
||||||
|
] as Array<[string, string]>,
|
||||||
|
onPick: (v: string) => setStatusFilter(v as StatusFilter),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
title: t('admin.plugins.sortBy'),
|
||||||
|
value: sort,
|
||||||
|
options:
|
||||||
|
view === 'discover'
|
||||||
|
? [
|
||||||
|
['name', t('admin.plugins.sortName')],
|
||||||
|
['recent', t('admin.plugins.sortRecent')],
|
||||||
|
['downloads', t('admin.plugins.sortDownloads')],
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
['name', t('admin.plugins.sortName')],
|
||||||
|
['recent', t('admin.plugins.sortRecent')],
|
||||||
|
['updates', t('admin.plugins.sortUpdates')],
|
||||||
|
],
|
||||||
|
onPick: (v) => setSort(v as SortKey),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const narrowing = (typeFilter !== 'all' ? 1 : 0) + (view === 'installed' && statusFilter !== 'all' ? 1 : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex-none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={btnRef}
|
||||||
|
onClick={() => setMenu(open ? null : MENU_ID)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
className={`inline-flex h-9 items-center gap-2 whitespace-nowrap rounded-[10px] border bg-surface-card px-3 text-content-secondary transition-colors hover:text-content ${
|
||||||
|
narrowing ? 'border-[color:var(--text-primary)]' : 'border-edge'
|
||||||
|
}`}
|
||||||
|
style={fs(12.5, 'body')}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={14} className="flex-none text-content-faint" />
|
||||||
|
<span className="font-semibold text-content">{t('admin.plugins.filters')}</span>
|
||||||
|
{narrowing > 0 && (
|
||||||
|
<span
|
||||||
|
className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-accent px-1.5 font-geist font-bold tabular-nums text-accent-text"
|
||||||
|
style={fs(10.5)}
|
||||||
|
>
|
||||||
|
{narrowing}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-content-faint">{sortLabel(sort, t)}</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={13}
|
||||||
|
className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="w-[240px] max-w-[calc(100vw-2rem)]"
|
||||||
|
style={{ ...POPOVER, ...anchorMenu(btnRef.current) }}
|
||||||
|
>
|
||||||
|
{sections.map((s, i) => (
|
||||||
|
<div key={s.title} className={i > 0 ? 'mt-1.5 border-t border-edge-faint pt-1.5' : undefined}>
|
||||||
|
<p
|
||||||
|
className="m-0 px-2.5 pb-1 pt-1 font-geist font-bold uppercase tracking-[.08em] text-content-faint"
|
||||||
|
style={fs(10)}
|
||||||
|
>
|
||||||
|
{s.title}
|
||||||
|
</p>
|
||||||
|
{s.options.map(([v, label]) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={s.value === v}
|
||||||
|
key={v}
|
||||||
|
onClick={() => s.onPick(v)}
|
||||||
|
className={`${MENU_ITEM} ${s.value === v ? 'font-semibold text-content' : 'text-content-secondary'}`}
|
||||||
|
style={fs(12.5, 'body')}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-start">{label}</span>
|
||||||
|
<Check
|
||||||
|
size={14}
|
||||||
|
className={`flex-none text-content-muted ${s.value === v ? 'opacity-100' : 'opacity-0'}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowUpCircle,
|
||||||
|
Bug,
|
||||||
|
CircleDot,
|
||||||
|
Github,
|
||||||
|
Globe,
|
||||||
|
History,
|
||||||
|
Info,
|
||||||
|
KeyRound,
|
||||||
|
Link2,
|
||||||
|
MoreHorizontal,
|
||||||
|
PauseCircle,
|
||||||
|
RotateCw,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Trash2,
|
||||||
|
UploadCloud,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useRef, type ComponentType, type ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { POPOVER } from '../../Packing/packingPopoverStyles';
|
||||||
|
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||||
|
import { fs } from '../../shared/DialogShell';
|
||||||
|
import PluginIcon from '../../shared/PluginIcon';
|
||||||
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
|
import { deriveCaps } from '../pluginCaps';
|
||||||
|
import { anchorMenu, MENU_ITEM, useFollowTrigger } from './pluginMenu';
|
||||||
|
import { deriveDeps, isRegistrySourced, parseJson, type PluginRow, type T } from './pluginModel';
|
||||||
|
|
||||||
|
// Runtime health → dot colour on the icon tile.
|
||||||
|
const HEALTH: Record<string, string> = {
|
||||||
|
active: 'bg-success',
|
||||||
|
starting: 'bg-info animate-pulse',
|
||||||
|
error: 'bg-danger',
|
||||||
|
inactive: 'bg-content-faint/60',
|
||||||
|
disabled: 'bg-warning',
|
||||||
|
incompatible: 'bg-warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Every fact about a plugin wears the same chip; only its icon carries a colour. */
|
||||||
|
const FACT =
|
||||||
|
'inline-flex max-w-full items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2 py-[2px] font-medium text-content-secondary';
|
||||||
|
|
||||||
|
type Tone = 'warning' | 'danger' | 'neutral';
|
||||||
|
const NOTICE_LOOK: Record<Tone, string> = {
|
||||||
|
warning: 'bg-warning-soft text-warning',
|
||||||
|
danger: 'bg-danger-soft text-danger',
|
||||||
|
neutral: 'bg-surface-secondary text-content-muted',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A state of the plugin worth reading on its own line: what it means, and the one action that fixes it. */
|
||||||
|
interface Notice {
|
||||||
|
key: string;
|
||||||
|
tone: Tone;
|
||||||
|
icon: ComponentType<{ size?: number; className?: string }>;
|
||||||
|
text: string;
|
||||||
|
action?: { label: string; onClick: () => void; disabled?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
function Fact({
|
||||||
|
icon,
|
||||||
|
iconClass = 'text-content-muted',
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
iconClass?: string;
|
||||||
|
hint?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const chip = (
|
||||||
|
<span className={FACT} style={fs(11)}>
|
||||||
|
<span className={`flex flex-none ${iconClass}`}>{icon}</span>
|
||||||
|
<span className="truncate">{children}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return hint ? (
|
||||||
|
<Tooltip label={hint}>
|
||||||
|
<span className="inline-flex max-w-full">{chip}</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
chip
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoticeLine({ n }: { n: Notice }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex min-w-0 items-center gap-2 rounded-[10px] px-2.5 py-1.5 ${NOTICE_LOOK[n.tone]}`}
|
||||||
|
style={fs(11.5)}
|
||||||
|
>
|
||||||
|
<n.icon size={13} className="flex-none" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{n.text}</span>
|
||||||
|
{n.action && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={n.action.onClick}
|
||||||
|
disabled={n.action.disabled}
|
||||||
|
className="flex-none font-semibold underline underline-offset-2 hover:opacity-80 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{n.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
danger,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`${MENU_ITEM} ${danger ? 'text-danger hover:bg-danger-soft' : 'text-content-secondary'}`}
|
||||||
|
style={fs(12.5, 'body')}
|
||||||
|
>
|
||||||
|
<span className={`flex w-4 flex-none justify-center ${danger ? 'text-danger' : 'text-content-muted'}`}>
|
||||||
|
{icon}
|
||||||
|
</span>{' '}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstalledRowProps {
|
||||||
|
p: PluginRow;
|
||||||
|
t: T;
|
||||||
|
busy: string | null;
|
||||||
|
menu: string | null;
|
||||||
|
setMenu: (v: string | null) => void;
|
||||||
|
hasUpdate: boolean;
|
||||||
|
latestVer?: string;
|
||||||
|
newerIncompatible: { version: string; range: string } | null;
|
||||||
|
blocked: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onUpdate: () => void;
|
||||||
|
onRestart: () => void;
|
||||||
|
onChangeVersion: () => void;
|
||||||
|
onResume: () => void;
|
||||||
|
onReviewBlock: () => void;
|
||||||
|
onErrors: () => void;
|
||||||
|
onEgress: () => void;
|
||||||
|
onSettings: () => void;
|
||||||
|
onUninstall: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One installed plugin, read top to bottom: its name and version, what it does, the
|
||||||
|
* facts about it as one row of alike chips (what it adds, whether it was reviewed and
|
||||||
|
* signed, where it may connect), then each state that needs attention on a line of its
|
||||||
|
* own with the action that resolves it. The controls stay on the right.
|
||||||
|
*/
|
||||||
|
export default function PluginInstalledRow(props: InstalledRowProps) {
|
||||||
|
const { p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked } = props;
|
||||||
|
const caps = deriveCaps(
|
||||||
|
parseJson<string[]>(p.permissions, []),
|
||||||
|
parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}),
|
||||||
|
t
|
||||||
|
);
|
||||||
|
const deps = deriveDeps(p, t);
|
||||||
|
const menuOpen = menu === `row:${p.id}`;
|
||||||
|
const menuBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const working = busy === p.id;
|
||||||
|
const hostCount = p.egressHostCount ?? 0;
|
||||||
|
|
||||||
|
// The menu is portaled to <body> and positioned `fixed` against the ⋯ button,
|
||||||
|
// because the row's ancestor (PageSidebar) is `overflow-hidden` and would clip
|
||||||
|
// an in-flow `absolute` menu — losing Delete on the lower rows of a long list.
|
||||||
|
useFollowTrigger(menuOpen);
|
||||||
|
|
||||||
|
const notices: Notice[] = [];
|
||||||
|
if (p.status === 'error' && p.last_error) {
|
||||||
|
notices.push({ key: 'error', tone: 'danger', icon: AlertTriangle, text: p.last_error });
|
||||||
|
}
|
||||||
|
// A refused update leaves a WORKING plugin pinned at its old version: its own state,
|
||||||
|
// kept until resolved rather than dying with the toast that first reported it.
|
||||||
|
if (blocked && p.updateBlock) {
|
||||||
|
notices.push({
|
||||||
|
key: 'block',
|
||||||
|
tone: 'warning',
|
||||||
|
icon: ShieldAlert,
|
||||||
|
text: t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code }),
|
||||||
|
action: { label: t('admin.plugins.reviewBlock'), onClick: props.onReviewBlock },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [i, d] of deps.entries()) {
|
||||||
|
if (d.blocked || d.warn) notices.push({ key: `dep${i}`, tone: 'warning', icon: d.icon, text: d.label });
|
||||||
|
}
|
||||||
|
// Held: the admin deliberately installed a non-latest version, so updates are paused
|
||||||
|
// rather than nagged — resuming is one click, right where the pause shows.
|
||||||
|
if (p.updateHold) {
|
||||||
|
notices.push({
|
||||||
|
key: 'hold',
|
||||||
|
tone: 'neutral',
|
||||||
|
icon: PauseCircle,
|
||||||
|
text: t('admin.plugins.updatesHeld', { version: p.version ?? '' }),
|
||||||
|
action: { label: t('admin.plugins.resumeUpdates'), onClick: props.onResume, disabled: working },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A newer version this TREK can't run: informational, never a button, the fix is a TREK upgrade.
|
||||||
|
if (newerIncompatible) {
|
||||||
|
notices.push({
|
||||||
|
key: 'newer',
|
||||||
|
tone: 'neutral',
|
||||||
|
icon: Info,
|
||||||
|
text: t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative flex items-start gap-3 px-3.5 py-3.5 transition-colors hover:bg-surface-secondary ${working ? 'opacity-60' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="relative mt-0.5 flex-none">
|
||||||
|
<span className="grid h-10 w-10 place-items-center rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||||
|
<PluginIcon name={p.icon} size={19} className="text-content-secondary" />
|
||||||
|
</span>
|
||||||
|
<Tooltip label={t(`admin.plugins.status.${p.status}` as never)}>
|
||||||
|
<span
|
||||||
|
className={`absolute -bottom-0.5 -end-0.5 h-3 w-3 rounded-full ring-2 ring-surface-card ${HEALTH[p.status] || HEALTH.inactive}`}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-baseline gap-2">
|
||||||
|
<span className="min-w-0 truncate font-semibold tracking-[-.006em] text-content" style={fs(13.5, 'body')}>
|
||||||
|
{p.name}
|
||||||
|
</span>
|
||||||
|
{p.version && (
|
||||||
|
<span className="flex-none font-geist font-medium tabular-nums text-content-faint" style={fs(11)}>
|
||||||
|
v{p.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{p.description && (
|
||||||
|
<p className="m-0 mt-0.5 truncate text-content-muted" style={fs(12, 'body')}>
|
||||||
|
{p.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||||
|
{caps.map((c, i) => (
|
||||||
|
<Fact key={i} icon={<c.icon size={12} />} iconClass={c.net ? 'text-info' : 'text-content-muted'}>
|
||||||
|
{c.label}
|
||||||
|
</Fact>
|
||||||
|
))}
|
||||||
|
{deps
|
||||||
|
.filter((d) => !d.blocked && !d.warn)
|
||||||
|
.map((d, i) => (
|
||||||
|
<Fact key={`d${i}`} icon={<d.icon size={12} />}>
|
||||||
|
{d.label}
|
||||||
|
</Fact>
|
||||||
|
))}
|
||||||
|
{p.reviewed_at && (
|
||||||
|
<Fact icon={<ShieldCheck size={12} />} iconClass="text-success">
|
||||||
|
{t('admin.plugins.reviewed')}
|
||||||
|
</Fact>
|
||||||
|
)}
|
||||||
|
{p.source_repo === 'local:upload' && (
|
||||||
|
<Fact icon={<UploadCloud size={12} />} iconClass="text-warning" hint={t('admin.plugins.sideloadedHint')}>
|
||||||
|
{t('admin.plugins.sideloaded')}
|
||||||
|
</Fact>
|
||||||
|
)}
|
||||||
|
{p.source_repo === 'local:link' && (
|
||||||
|
<Fact icon={<Link2 size={12} />} hint={t('admin.plugins.devLinkHint')}>
|
||||||
|
{t('admin.plugins.devLinkBadge')}
|
||||||
|
</Fact>
|
||||||
|
)}
|
||||||
|
{/* Registry plugins only: a sideloaded or dev-linked plugin already says something stronger. */}
|
||||||
|
{isRegistrySourced(p.source_repo) &&
|
||||||
|
(p.signed ? (
|
||||||
|
<Fact icon={<KeyRound size={12} />} hint={t('admin.plugins.signedHint')}>
|
||||||
|
{t('admin.plugins.signed')}
|
||||||
|
</Fact>
|
||||||
|
) : (
|
||||||
|
<Fact icon={<ShieldAlert size={12} />} iconClass="text-warning" hint={t('admin.plugins.unsignedHint')}>
|
||||||
|
{t('admin.plugins.unsigned')}
|
||||||
|
</Fact>
|
||||||
|
))}
|
||||||
|
{/* A service only the operator can name (a self-hosted Gotify/ntfy): the admin adds
|
||||||
|
its host, and until one exists the plugin reaches nothing, so it asks for it. */}
|
||||||
|
{p.operatorEgress && (
|
||||||
|
<Tooltip label={t('admin.plugins.allowedHosts.hint')}>
|
||||||
|
<button type="button" onClick={props.onEgress} className={`${FACT} hover:opacity-80`} style={fs(11)}>
|
||||||
|
<Globe size={12} className={hostCount > 0 ? 'text-info' : 'text-warning'} />
|
||||||
|
{hostCount > 0
|
||||||
|
? t('admin.plugins.allowedHosts.count', { n: hostCount })
|
||||||
|
: t('admin.plugins.allowedHosts.add')}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notices.length > 0 && (
|
||||||
|
<div className="mt-2.5 flex flex-col gap-1.5">
|
||||||
|
{notices.map((n) => (
|
||||||
|
<NoticeLine key={n.key} n={n} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-none items-center gap-2 self-center">
|
||||||
|
{hasUpdate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onUpdate}
|
||||||
|
disabled={working}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-1 font-semibold text-warning hover:opacity-90 disabled:opacity-50"
|
||||||
|
style={fs(11.5)}
|
||||||
|
>
|
||||||
|
<ArrowUpCircle size={13} /> <span>{t('admin.plugins.updateTo', { version: latestVer })}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`min-w-[42px] text-end font-medium ${p.enabled === 1 ? 'text-content-secondary' : 'text-content-faint'}`}
|
||||||
|
style={fs(12)}
|
||||||
|
>
|
||||||
|
{p.enabled === 1 ? t('admin.plugins.status.active') : t('admin.plugins.stateOff')}
|
||||||
|
</span>
|
||||||
|
<ToggleSwitch on={p.enabled === 1} label={t('admin.plugins.enabledToggle')} onToggle={props.onToggle} />
|
||||||
|
<div className="relative">
|
||||||
|
<Tooltip label={t('admin.table.actions')} disabled={menuOpen}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={menuBtnRef}
|
||||||
|
data-testid={`plugin-row-menu-btn-${p.id}`}
|
||||||
|
onClick={() => setMenu(menuOpen ? null : `row:${p.id}`)}
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
aria-label={t('admin.table.actions')}
|
||||||
|
className={`grid h-8 w-8 place-items-center rounded-[10px] transition-colors ${menuOpen ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-faint hover:bg-surface-card hover:text-content'}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={17} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
{menuOpen &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
data-testid={`plugin-row-menu-${p.id}`}
|
||||||
|
style={{ ...POPOVER, ...anchorMenu(menuBtnRef.current) }}
|
||||||
|
className="min-w-[200px]"
|
||||||
|
>
|
||||||
|
{p.enabled === 1 && (
|
||||||
|
<MenuItem
|
||||||
|
icon={<RotateCw size={14} />}
|
||||||
|
label={t('admin.plugins.restart')}
|
||||||
|
onClick={props.onRestart}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Instance fields OR actions: an action-only plugin still needs the dialog to run its buttons. */}
|
||||||
|
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
|
||||||
|
<MenuItem
|
||||||
|
icon={<SlidersHorizontal size={14} />}
|
||||||
|
label={t('admin.plugins.instanceSettings')}
|
||||||
|
onClick={props.onSettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={props.onErrors} />
|
||||||
|
{/* Only a plugin that DECLARED operatorEgress: an admin is never invited to widen egress for one that did not ask. */}
|
||||||
|
{p.operatorEgress && (
|
||||||
|
<MenuItem
|
||||||
|
icon={<Globe size={14} />}
|
||||||
|
label={t('admin.plugins.allowedHosts')}
|
||||||
|
onClick={props.onEgress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
|
||||||
|
{isRegistrySourced(p.source_repo) && (
|
||||||
|
<MenuItem
|
||||||
|
icon={<History size={14} />}
|
||||||
|
label={t('admin.plugins.changeVersion')}
|
||||||
|
onClick={props.onChangeVersion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${p.source_repo}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={() => setMenu(null)}
|
||||||
|
className={`${MENU_ITEM} text-content-secondary`}
|
||||||
|
style={fs(12.5, 'body')}
|
||||||
|
>
|
||||||
|
<span className="flex w-4 flex-none justify-center text-content-muted">
|
||||||
|
<Github size={14} />
|
||||||
|
</span>{' '}
|
||||||
|
{t('admin.plugins.sourceRepo')}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${p.source_repo}/issues`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={() => setMenu(null)}
|
||||||
|
className={`${MENU_ITEM} text-content-secondary`}
|
||||||
|
style={fs(12.5, 'body')}
|
||||||
|
>
|
||||||
|
<span className="flex w-4 flex-none justify-center text-content-muted">
|
||||||
|
<CircleDot size={14} />
|
||||||
|
</span>{' '}
|
||||||
|
{t('admin.plugins.reportIssue')}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="mx-1.5 my-1 h-px bg-edge-faint" />
|
||||||
|
<MenuItem icon={<Trash2 size={14} />} label={t('common.delete')} danger onClick={props.onUninstall} />
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { ShieldCheck } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DialogButton,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogShell,
|
||||||
|
DialogTile,
|
||||||
|
FooterSpacer,
|
||||||
|
NEUTRAL_TINT,
|
||||||
|
fs,
|
||||||
|
} from '../../shared/DialogShell';
|
||||||
|
import type { T } from './pluginModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How plugins are contained and where that ends, opened from the shield in the panel's
|
||||||
|
* header. It used to sit under the list as a card of its own, which pushed a wall of
|
||||||
|
* text into the page every admin scrolls past.
|
||||||
|
*/
|
||||||
|
export default function PluginSecurityDialog({ t, onClose }: { t: T; onClose: () => void }) {
|
||||||
|
const sections: Array<[string, string]> = [
|
||||||
|
[t('admin.plugins.security.isolationTitle'), t('admin.plugins.security.isolationBody')],
|
||||||
|
[t('admin.plugins.security.permsTitle'), t('admin.plugins.security.permsBody')],
|
||||||
|
[t('admin.plugins.security.limitsTitle'), t('admin.plugins.security.limitsBody')],
|
||||||
|
[t('admin.plugins.security.worstTitle'), t('admin.plugins.security.worstBody')],
|
||||||
|
[t('admin.plugins.security.reviewedTitle'), t('admin.plugins.security.reviewedBody')],
|
||||||
|
[t('admin.plugins.security.signedTitle'), t('admin.plugins.security.signedBody')],
|
||||||
|
[t('admin.plugins.security.trustTitle'), t('admin.plugins.security.trustBody')],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<DialogShell
|
||||||
|
onClose={onClose}
|
||||||
|
labelledBy="plugin-security-title"
|
||||||
|
width="detail"
|
||||||
|
header={
|
||||||
|
<DialogHeader
|
||||||
|
tile={
|
||||||
|
<DialogTile>
|
||||||
|
<ShieldCheck size={20} className="text-content-secondary" />
|
||||||
|
</DialogTile>
|
||||||
|
}
|
||||||
|
tint={NEUTRAL_TINT}
|
||||||
|
labelId="plugin-security-title"
|
||||||
|
title={t('admin.plugins.security.short')}
|
||||||
|
sub={t('admin.plugins.reviewedMeaning')}
|
||||||
|
subWraps
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<DialogFooter>
|
||||||
|
<FooterSpacer />
|
||||||
|
<DialogButton variant="primary" onClick={onClose}>
|
||||||
|
{t('common.ok')}
|
||||||
|
</DialogButton>
|
||||||
|
</DialogFooter>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid gap-2.5 sm:grid-cols-2">
|
||||||
|
{sections.map(([h, b]) => (
|
||||||
|
<div key={h} className="rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3">
|
||||||
|
<h4 className="m-0 font-semibold text-content" style={fs(12.5, 'body')}>
|
||||||
|
{h}
|
||||||
|
</h4>
|
||||||
|
<p className="m-0 mt-1 leading-relaxed text-content-muted" style={fs(12, 'body')}>
|
||||||
|
{b}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState, type CSSProperties } from 'react';
|
||||||
|
|
||||||
|
/** The tallest a row's ⋯ menu gets; below that it opens upwards instead. */
|
||||||
|
const ROW_MENU_MAX_H = 260;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a portaled menu sits: right-aligned under its trigger, or above it when the
|
||||||
|
* window has no room below. Fixed, because the cards around the toolbar and the rows
|
||||||
|
* clip whatever overflows them.
|
||||||
|
*/
|
||||||
|
export function anchorMenu(trigger: HTMLElement | null): CSSProperties {
|
||||||
|
const r = trigger?.getBoundingClientRect();
|
||||||
|
if (!r) return { position: 'fixed', top: 0, right: 0, zIndex: 100 };
|
||||||
|
const spaceBelow = window.innerHeight - r.bottom;
|
||||||
|
const openUp = spaceBelow < ROW_MENU_MAX_H && r.top > spaceBelow;
|
||||||
|
return {
|
||||||
|
position: 'fixed',
|
||||||
|
right: Math.max(8, window.innerWidth - r.right),
|
||||||
|
...(openUp ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }),
|
||||||
|
zIndex: 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry of a popover menu, in the packing popovers' shape. */
|
||||||
|
export const MENU_ITEM =
|
||||||
|
'flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-2 font-medium transition-colors hover:bg-surface-tertiary';
|
||||||
|
|
||||||
|
/** Re-renders while the page moves under an open, portaled menu, so it stays pinned to its trigger. */
|
||||||
|
export function useFollowTrigger(open: boolean) {
|
||||||
|
const [, reanchor] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onMove = () => reanchor((n) => n + 1);
|
||||||
|
window.addEventListener('scroll', onMove, true);
|
||||||
|
window.addEventListener('resize', onMove);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', onMove, true);
|
||||||
|
window.removeEventListener('resize', onMove);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
}
|
||||||
@@ -111,8 +111,14 @@ describe('pluginModel', () => {
|
|||||||
blocked: false,
|
blocked: false,
|
||||||
version: '0.9.0',
|
version: '0.9.0',
|
||||||
});
|
});
|
||||||
expect(installOffer(incompatible, t)).toMatchObject({ blocked: true, label: 'admin.plugins.incompatible' });
|
// Nothing fits this TREK: still an Install, behind the admin's own warning for this plugin.
|
||||||
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, label: 'admin.plugins.installAnyway' });
|
expect(installOffer(incompatible, t)).toMatchObject({
|
||||||
|
blocked: false,
|
||||||
|
label: 'admin.plugins.install',
|
||||||
|
warn: { acceptedByAdmin: true, trekRange: '>=9', hostVersion: '4.0.0' },
|
||||||
|
});
|
||||||
|
// TREK_PLUGINS_IGNORE_TREK_RANGE lets every plugin through, so the warning is not the admin's own choice.
|
||||||
|
expect(installOffer(incompatible, t, true)).toMatchObject({ blocked: false, warn: { acceptedByAdmin: false } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-ADMIN-PLUGIN-MODEL-008: deriveDeps marks the blockers among the declared dependencies', () => {
|
it('FE-ADMIN-PLUGIN-MODEL-008: deriveDeps marks the blockers among the declared dependencies', () => {
|
||||||
|
|||||||
@@ -298,7 +298,8 @@ export function installOffer(
|
|||||||
title,
|
title,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { blocked: true, label: t('admin.plugins.incompatible'), title };
|
// No version fits this TREK: the admin may still install this one plugin at their own risk.
|
||||||
|
return bypassOffer(item, t, title, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-ADMIN-PLUGINS-HOOK-001 to -012: the plugin admin logic behind both admin shells.
|
// FE-ADMIN-PLUGINS-HOOK-001 to -014: the plugin admin logic behind both admin shells.
|
||||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
import type { DragEvent } from 'react';
|
import type { DragEvent } from 'react';
|
||||||
|
|
||||||
@@ -266,4 +266,36 @@ describe('usePluginsAdmin', () => {
|
|||||||
expect(toast.success).toHaveBeenCalledWith('admin.plugins.uninstalled');
|
expect(toast.success).toHaveBeenCalledWith('admin.plugins.uninstalled');
|
||||||
expect(result.current.confirmUninstall).toBeNull();
|
expect(result.current.confirmUninstall).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PLUGINS-HOOK-013: switching on a plugin outside its TREK range asks first, then runs it anyway', async () => {
|
||||||
|
serve([
|
||||||
|
row({ enabled: 0, dependencyStatus: 'hostIncompatible', trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0' }),
|
||||||
|
]);
|
||||||
|
vi.spyOn(adminApi, 'pluginActivate').mockResolvedValue({});
|
||||||
|
const { result } = await mounted();
|
||||||
|
act(() => result.current.toggle(result.current.plugins[0] as PluginRow));
|
||||||
|
expect(adminApi.pluginActivate).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.bypass.warning).toMatchObject({
|
||||||
|
acceptedByAdmin: true,
|
||||||
|
action: 'activate',
|
||||||
|
trekRange: '>=3.2.0 <4.0.0',
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
result.current.bypass.confirm();
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(adminApi.pluginActivate).toHaveBeenCalledWith('alpha', false, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PLUGINS-HOOK-014: installing behind the admin warning sends their consent for this plugin', async () => {
|
||||||
|
serve([], [entry({ id: 'beta', name: 'Beta' })]);
|
||||||
|
vi.spyOn(adminApi, 'pluginInstall').mockResolvedValue({ id: 'beta', version: '1.0.0', trekRangeBypassed: null });
|
||||||
|
const { result } = await mounted();
|
||||||
|
const warn = { name: 'Beta', trekRange: '<5.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: null };
|
||||||
|
act(() => result.current.install('beta', undefined, warn));
|
||||||
|
expect(adminApi.pluginInstall).not.toHaveBeenCalled();
|
||||||
|
await act(async () => {
|
||||||
|
result.current.bypass.confirm();
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(adminApi.pluginInstall).toHaveBeenCalledWith('beta', { acceptTrekRange: true }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -317,9 +317,14 @@ export function usePluginsAdmin() {
|
|||||||
act(
|
act(
|
||||||
id,
|
id,
|
||||||
() =>
|
() =>
|
||||||
adminApi.pluginInstall(id, version ? { version } : undefined).then((r) => {
|
adminApi
|
||||||
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
|
.pluginInstall(id, {
|
||||||
}),
|
...(version ? { version } : {}),
|
||||||
|
...(warn?.acceptedByAdmin ? { acceptTrekRange: true } : {}),
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (!warn) bypass.notice(id, r?.trekRangeBypassed);
|
||||||
|
}),
|
||||||
t('admin.plugins.installed')
|
t('admin.plugins.installed')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -392,10 +397,10 @@ export function usePluginsAdmin() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const attemptActivate = (p: PluginRow) => {
|
const attemptActivate = (p: PluginRow, acceptTrekRange = false) => {
|
||||||
const cascaded = autoEnabledDeps(p);
|
const cascaded = autoEnabledDeps(p);
|
||||||
return adminApi
|
return adminApi
|
||||||
.pluginActivate(p.id)
|
.pluginActivate(p.id, false, acceptTrekRange)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(t('admin.plugins.activated'));
|
toast.success(t('admin.plugins.activated'));
|
||||||
if (cascaded.length)
|
if (cascaded.length)
|
||||||
@@ -415,12 +420,29 @@ export function usePluginsAdmin() {
|
|||||||
void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated'));
|
void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(p.id);
|
const run = (acceptTrekRange: boolean) => {
|
||||||
setMenu(null);
|
setBusy(p.id);
|
||||||
attemptActivate(p).finally(() => {
|
setMenu(null);
|
||||||
setBusy(null);
|
attemptActivate(p, acceptTrekRange).finally(() => {
|
||||||
refresh();
|
setBusy(null);
|
||||||
});
|
refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// Outside the TREK versions its author declared: the admin may run it anyway, after
|
||||||
|
// reading what that risks. The choice holds for this version of the plugin only.
|
||||||
|
if (p.dependencyStatus === 'hostIncompatible') {
|
||||||
|
const warn: RangeWarning = {
|
||||||
|
name: p.name,
|
||||||
|
trekRange: p.trekRange ?? null,
|
||||||
|
hostVersion: p.hostVersion ?? '?',
|
||||||
|
acceptedByAdmin: true,
|
||||||
|
onConfirm: null,
|
||||||
|
action: 'activate',
|
||||||
|
};
|
||||||
|
bypass.guard(warn, () => run(true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Download a missing/outdated plugin dependency (latest compatible for its range,
|
// Download a missing/outdated plugin dependency (latest compatible for its range,
|
||||||
|
|||||||
@@ -16,15 +16,23 @@ describe('rangeBypass helpers', () => {
|
|||||||
blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}',
|
blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}',
|
||||||
})
|
})
|
||||||
expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' })
|
expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' })
|
||||||
|
// An admin's own "run it anyway" for this one plugin says so, not "version checks off".
|
||||||
|
expect(bypassChip({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({
|
||||||
|
warn: true, label: 'admin.plugins.dep.trekAccepted:{"range":">=3.2.0 <4.0.0","host":"5.0.0"}',
|
||||||
|
})
|
||||||
|
expect(bypassChip({ trekRange: null, hostVersion: '5.0.0', acceptedByAdmin: true }, t)).toMatchObject({
|
||||||
|
label: 'admin.plugins.dep.trekAcceptedUnknown',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('bypassOffer: an enabled "Install anyway" carrying what the dialog must say', () => {
|
it('bypassOffer: an enabled Install that carries what the dialog must say first', () => {
|
||||||
const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
|
const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
|
||||||
expect(offer).toEqual({
|
expect(offer).toEqual({
|
||||||
blocked: false, label: 'admin.plugins.installAnyway', title: 'why',
|
blocked: false, label: 'admin.plugins.install', title: 'why',
|
||||||
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null },
|
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false, onConfirm: null },
|
||||||
})
|
})
|
||||||
expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' })
|
expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' })
|
||||||
|
expect(bypassOffer({ name: 'X' }, t, 'why', true).warn).toMatchObject({ acceptedByAdmin: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
|
it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
|
||||||
@@ -32,10 +40,25 @@ describe('rangeBypass helpers', () => {
|
|||||||
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
|
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
|
||||||
confirm: true, title: 'admin.plugins.rangeBypass.title',
|
confirm: true, title: 'admin.plugins.rangeBypass.title',
|
||||||
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}',
|
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}',
|
||||||
|
confirmLabel: 'admin.plugins.installAnyway',
|
||||||
})
|
})
|
||||||
expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
|
expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
|
||||||
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle',
|
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle',
|
||||||
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","host":"3.3.0"}',
|
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","range":null,"host":"3.3.0"}',
|
||||||
|
confirmLabel: 'admin.plugins.installAnyway',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rangeWarningCopy: an admin choosing for one plugin gets the short title, their own body and the right button', () => {
|
||||||
|
const mine = { name: 'G', trekRange: '>=4.0.0', hostVersion: '5.0.0', acceptedByAdmin: true, onConfirm: () => {} }
|
||||||
|
expect(rangeWarningCopy(mine, t)).toEqual({
|
||||||
|
confirm: true, title: 'admin.plugins.rangeBypass.titleAdmin',
|
||||||
|
body: 'admin.plugins.rangeBypass.bodyAdmin:{"name":"G","range":">=4.0.0","host":"5.0.0"}',
|
||||||
|
confirmLabel: 'admin.plugins.installAnyway',
|
||||||
|
})
|
||||||
|
expect(rangeWarningCopy({ ...mine, trekRange: null, action: 'activate' }, t)).toMatchObject({
|
||||||
|
body: 'admin.plugins.rangeBypass.bodyAdminUnknown:{"name":"G","range":null,"host":"5.0.0"}',
|
||||||
|
confirmLabel: 'admin.plugins.activateAnyway',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,10 +4,21 @@ import { useTranslation } from '../../i18n'
|
|||||||
|
|
||||||
type T = (k: string, p?: Record<string, unknown>) => string
|
type T = (k: string, p?: Record<string, unknown>) => string
|
||||||
|
|
||||||
/** The server's "this only went through because TREK_PLUGINS_IGNORE_TREK_RANGE is set" marker. */
|
/**
|
||||||
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string }
|
* The server's "this only went through because the version check was set aside" marker:
|
||||||
/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */
|
* by an admin for this one plugin (`acceptedByAdmin`), or for every plugin through
|
||||||
export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null }
|
* TREK_PLUGINS_IGNORE_TREK_RANGE.
|
||||||
|
*/
|
||||||
|
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string; acceptedByAdmin?: boolean }
|
||||||
|
/**
|
||||||
|
* What the range-bypass warning needs to say, and what to do if the admin accepts (null = a
|
||||||
|
* notice). `action` names what the confirm button does: install (the default) or activate.
|
||||||
|
*/
|
||||||
|
export interface RangeWarning extends TrekRangeBypass {
|
||||||
|
name: string
|
||||||
|
onConfirm: (() => void) | null
|
||||||
|
action?: 'install' | 'activate'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chip a row carries while it runs outside its declared range on the operator's
|
* The chip a row carries while it runs outside its declared range on the operator's
|
||||||
@@ -16,29 +27,42 @@ export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm:
|
|||||||
*/
|
*/
|
||||||
export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
|
export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
|
||||||
if (!b) return null
|
if (!b) return null
|
||||||
|
const label = b.acceptedByAdmin
|
||||||
|
? b.trekRange
|
||||||
|
? t('admin.plugins.dep.trekAccepted', { range: b.trekRange, host: b.hostVersion })
|
||||||
|
: t('admin.plugins.dep.trekAcceptedUnknown')
|
||||||
|
: b.trekRange
|
||||||
|
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
|
||||||
|
: t('admin.plugins.dep.trekBypassedUnknown')
|
||||||
return {
|
return {
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
label: b.trekRange
|
label,
|
||||||
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
|
|
||||||
: t('admin.plugins.dep.trekBypassedUnknown'),
|
|
||||||
blocked: false,
|
blocked: false,
|
||||||
warn: true,
|
warn: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Install button for a registry entry the server flagged incompatible, once the
|
* The Install button for a registry entry the server flagged incompatible: the server will
|
||||||
* operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version
|
* take the newest version regardless, so the button says exactly that — and carries what
|
||||||
* regardless, so the button says exactly that — and carries what the warning has to say
|
* the warning has to say first. `byAdmin` is the admin's own "install anyway" for this one
|
||||||
* first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
|
* plugin; without it the operator set TREK_PLUGINS_IGNORE_TREK_RANGE for every plugin.
|
||||||
|
* `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
|
||||||
*/
|
*/
|
||||||
export function bypassOffer(item: { name: string; trek?: string | null; hostVersion?: string }, t: T, title: string) {
|
export function bypassOffer(
|
||||||
return {
|
item: { name: string; trek?: string | null; hostVersion?: string },
|
||||||
blocked: false,
|
t: T,
|
||||||
label: t('admin.plugins.installAnyway'),
|
title: string,
|
||||||
title,
|
byAdmin = false
|
||||||
warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning,
|
) {
|
||||||
|
const warn: RangeWarning = {
|
||||||
|
name: item.name,
|
||||||
|
trekRange: item.trek ?? null,
|
||||||
|
hostVersion: item.hostVersion ?? '?',
|
||||||
|
acceptedByAdmin: byAdmin,
|
||||||
|
onConfirm: null,
|
||||||
}
|
}
|
||||||
|
return { blocked: false, label: t('admin.plugins.install'), title, warn }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,14 +72,29 @@ export function bypassOffer(item: { name: string; trek?: string | null; hostVers
|
|||||||
* whole point: the admin is accepting that the author never vouched for this TREK and
|
* whole point: the admin is accepting that the author never vouched for this TREK and
|
||||||
* that, in rare cases, that can cost them data.
|
* that, in rare cases, that can cost them data.
|
||||||
*/
|
*/
|
||||||
export function rangeWarningCopy(w: RangeWarning, t: T): { title: string; body: string; confirm: boolean } {
|
export function rangeWarningCopy(
|
||||||
|
w: RangeWarning,
|
||||||
|
t: T
|
||||||
|
): { title: string; body: string; confirm: boolean; confirmLabel: string } {
|
||||||
const confirm = w.onConfirm !== null
|
const confirm = w.onConfirm !== null
|
||||||
|
const vars = { name: w.name, range: w.trekRange, host: w.hostVersion }
|
||||||
|
const body = w.acceptedByAdmin
|
||||||
|
? w.trekRange
|
||||||
|
? t('admin.plugins.rangeBypass.bodyAdmin', vars)
|
||||||
|
: t('admin.plugins.rangeBypass.bodyAdminUnknown', vars)
|
||||||
|
: w.trekRange
|
||||||
|
? t('admin.plugins.rangeBypass.body', vars)
|
||||||
|
: t('admin.plugins.rangeBypass.bodyUnknown', vars)
|
||||||
|
const title = w.acceptedByAdmin
|
||||||
|
? t('admin.plugins.rangeBypass.titleAdmin')
|
||||||
|
: confirm
|
||||||
|
? t('admin.plugins.rangeBypass.title')
|
||||||
|
: t('admin.plugins.rangeBypass.noticeTitle')
|
||||||
return {
|
return {
|
||||||
confirm,
|
confirm,
|
||||||
title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'),
|
title,
|
||||||
body: w.trekRange
|
body,
|
||||||
? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion })
|
confirmLabel: w.action === 'activate' ? t('admin.plugins.activateAnyway') : t('admin.plugins.installAnyway'),
|
||||||
: t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -440,6 +440,8 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
|||||||
currency: { labelKey: 'assistant.change.label.currency' },
|
currency: { labelKey: 'assistant.change.label.currency' },
|
||||||
stopType: { labelKey: 'assistant.change.label.stopType' },
|
stopType: { labelKey: 'assistant.change.label.stopType' },
|
||||||
text: { labelKey: 'assistant.change.label.text' },
|
text: { labelKey: 'assistant.change.label.text' },
|
||||||
|
title: { labelKey: 'assistant.change.label.title' },
|
||||||
|
content: { labelKey: 'assistant.change.label.content' },
|
||||||
icon: { labelKey: 'assistant.change.label.icon' },
|
icon: { labelKey: 'assistant.change.label.icon' },
|
||||||
color: { labelKey: 'assistant.change.label.color' },
|
color: { labelKey: 'assistant.change.label.color' },
|
||||||
position: { labelKey: 'assistant.change.label.position' },
|
position: { labelKey: 'assistant.change.label.position' },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-030
|
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-032
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import en from '@trek/shared/i18n/en'
|
import en from '@trek/shared/i18n/en'
|
||||||
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
|
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
|
||||||
@@ -66,6 +66,7 @@ function renderCard(
|
|||||||
canManage?: boolean
|
canManage?: boolean
|
||||||
providerName?: string
|
providerName?: string
|
||||||
onReconnect?: () => void
|
onReconnect?: () => void
|
||||||
|
onEditConnection?: () => void
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const utils = render(
|
const utils = render(
|
||||||
@@ -75,6 +76,7 @@ function renderCard(
|
|||||||
sync={makeSync(opts.sync)}
|
sync={makeSync(opts.sync)}
|
||||||
canManage={opts.canManage ?? true}
|
canManage={opts.canManage ?? true}
|
||||||
onReconnect={opts.onReconnect}
|
onReconnect={opts.onReconnect}
|
||||||
|
onEditConnection={opts.onEditConnection}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
const header = utils.container.querySelector('header') as HTMLElement
|
const header = utils.container.querySelector('header') as HTMLElement
|
||||||
@@ -421,3 +423,31 @@ describe('DocSyncBinding: the direction lanes', () => {
|
|||||||
expect(updateLink).not.toHaveBeenCalled()
|
expect(updateLink).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('DocSyncBinding: the connection behind it', () => {
|
||||||
|
const S = en as unknown as Record<string, string>
|
||||||
|
const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] })
|
||||||
|
|
||||||
|
it('FE-DOCSYNC-BIND-031: the settings open the stored connection at any time, not only after a refusal', () => {
|
||||||
|
const onEditConnection = vi.fn()
|
||||||
|
renderCard({ link: { lastSyncState: 'ok' }, onEditConnection })
|
||||||
|
|
||||||
|
expect(editButton()).not.toBeInTheDocument()
|
||||||
|
openSettings()
|
||||||
|
expect(screen.getByText(S['docsync.connection.title'])).toBeInTheDocument()
|
||||||
|
fireEvent.click(editButton() as HTMLElement)
|
||||||
|
|
||||||
|
expect(onEditConnection).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-DOCSYNC-BIND-032: a member, or a panel without the form, gets no way to the connection', () => {
|
||||||
|
const member = renderCard({ canManage: false, onEditConnection: vi.fn() })
|
||||||
|
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
|
||||||
|
expect(editButton()).not.toBeInTheDocument()
|
||||||
|
member.unmount()
|
||||||
|
|
||||||
|
renderCard()
|
||||||
|
openSettings()
|
||||||
|
expect(editButton()).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, Link2Off, RefreshCw,
|
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, KeyRound, Link2Off, RefreshCw,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import ConfirmDialog from '../../shared/ConfirmDialog'
|
import ConfirmDialog from '../../shared/ConfirmDialog'
|
||||||
import CustomSelect from '../../shared/CustomSelect'
|
import CustomSelect from '../../shared/CustomSelect'
|
||||||
@@ -27,6 +27,7 @@ export default function DocSyncBinding({
|
|||||||
sync,
|
sync,
|
||||||
canManage,
|
canManage,
|
||||||
onReconnect,
|
onReconnect,
|
||||||
|
onEditConnection,
|
||||||
}: {
|
}: {
|
||||||
link: DocSyncLink
|
link: DocSyncLink
|
||||||
providerName: string
|
providerName: string
|
||||||
@@ -38,6 +39,8 @@ export default function DocSyncBinding({
|
|||||||
* not carry; left out, a refused credential is reported but not curable.
|
* not carry; left out, a refused credential is reported but not curable.
|
||||||
*/
|
*/
|
||||||
onReconnect?: () => void
|
onReconnect?: () => void
|
||||||
|
/** The same form at any time, to change the address or the login, or to remove the connection. */
|
||||||
|
onEditConnection?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -237,6 +240,21 @@ export default function DocSyncBinding({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onEditConnection && (
|
||||||
|
<Row label={t('docsync.connection.title')} hint={t('docsync.connection.hint')}>
|
||||||
|
<span className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditConnection}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
<KeyRound size={13} />
|
||||||
|
{t('docsync.connection.edit')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-027
|
// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-030
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import en from '@trek/shared/i18n/en'
|
import en from '@trek/shared/i18n/en'
|
||||||
import { render, screen, fireEvent, waitFor, act, within } from '../../../../tests/helpers/render'
|
import { render, screen, fireEvent, waitFor, act, within } from '../../../../tests/helpers/render'
|
||||||
@@ -13,6 +13,7 @@ const listLinks = vi.fn(async (_tripId: number | string): Promise<unknown> => []
|
|||||||
const status = vi.fn(async (_tripId: number | string): Promise<unknown> => ({ items: {}, links: [] }))
|
const status = vi.fn(async (_tripId: number | string): Promise<unknown> => ({ items: {}, links: [] }))
|
||||||
const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ id: 1 }))
|
const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ id: 1 }))
|
||||||
const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
|
const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
|
||||||
|
const deleteConnection = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
|
||||||
|
|
||||||
vi.mock('../../../api/client', async (importOriginal) => {
|
vi.mock('../../../api/client', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('../../../api/client')>()
|
const actual = await importOriginal<typeof import('../../../api/client')>()
|
||||||
@@ -26,6 +27,7 @@ vi.mock('../../../api/client', async (importOriginal) => {
|
|||||||
listLinks: (tripId: number | string) => listLinks(tripId),
|
listLinks: (tripId: number | string) => listLinks(tripId),
|
||||||
saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data),
|
saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data),
|
||||||
testConnection: (tripId: number | string, data: unknown) => testConnection(tripId, data),
|
testConnection: (tripId: number | string, data: unknown) => testConnection(tripId, data),
|
||||||
|
deleteConnection: (tripId: number | string, id: number) => deleteConnection(tripId, id),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -81,11 +83,13 @@ function Harness({
|
|||||||
provider,
|
provider,
|
||||||
onClose,
|
onClose,
|
||||||
onConnected,
|
onConnected,
|
||||||
|
onRemoved,
|
||||||
waitForConnection,
|
waitForConnection,
|
||||||
}: {
|
}: {
|
||||||
provider: DocSyncProvider
|
provider: DocSyncProvider
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onConnected: (providerId: string) => void
|
onConnected: (providerId: string) => void
|
||||||
|
onRemoved?: () => void
|
||||||
/**
|
/**
|
||||||
* Hold the dialog back until the stored connection is in hand, the way the
|
* Hold the dialog back until the stored connection is in hand, the way the
|
||||||
* panel does: it only offers "connect" once it knows what is already there.
|
* panel does: it only offers "connect" once it knows what is already there.
|
||||||
@@ -94,7 +98,9 @@ function Harness({
|
|||||||
}) {
|
}) {
|
||||||
const sync = useDocSync(TRIP_ID, true)
|
const sync = useDocSync(TRIP_ID, true)
|
||||||
if (waitForConnection && !sync.connectionFor(provider.id)) return null
|
if (waitForConnection && !sync.connectionFor(provider.id)) return null
|
||||||
return <DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} />
|
return (
|
||||||
|
<DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} onRemoved={onRemoved} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Let the hook's initial load land before anything is asserted. */
|
/** Let the hook's initial load land before anything is asserted. */
|
||||||
@@ -492,3 +498,52 @@ describe('DocSyncConnectModal with a connection already stored', () => {
|
|||||||
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ baseUrl: 'https://cloud.example.com' }))
|
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ baseUrl: 'https://cloud.example.com' }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('DocSyncConnectModal removal', () => {
|
||||||
|
const removeButton = () => screen.queryByRole('button', { name: tx('docsync.connection.remove') })
|
||||||
|
// The confirmation is portalled after the dialog, so its button is the last one of that name.
|
||||||
|
const confirmRemoval = () => {
|
||||||
|
const named = screen.getAllByRole('button', { name: tx('docsync.connection.remove') })
|
||||||
|
return named[named.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWithRemoval(stored: boolean) {
|
||||||
|
if (stored) listConnections.mockResolvedValue([connection()])
|
||||||
|
const onRemoved = vi.fn()
|
||||||
|
render(<Harness provider={nextcloud} onClose={vi.fn()} onConnected={vi.fn()} onRemoved={onRemoved} waitForConnection={stored} />)
|
||||||
|
await flush()
|
||||||
|
return onRemoved
|
||||||
|
}
|
||||||
|
|
||||||
|
it('FE-DOCSYNC-CONNECT-028: a stored connection can be removed after a confirmation, and the dialog is told', async () => {
|
||||||
|
const onRemoved = await openWithRemoval(true)
|
||||||
|
await waitFor(() => expect(removeButton()).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: tx('docsync.connection.remove') }))
|
||||||
|
expect(deleteConnection).not.toHaveBeenCalled()
|
||||||
|
await screen.findByText(tx('docsync.connection.confirmRemove'))
|
||||||
|
fireEvent.click(confirmRemoval())
|
||||||
|
|
||||||
|
await waitFor(() => expect(deleteConnection).toHaveBeenCalledWith(TRIP_ID, 9))
|
||||||
|
await waitFor(() => expect(onRemoved).toHaveBeenCalled())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-DOCSYNC-CONNECT-029: a refused removal keeps the dialog and says why', async () => {
|
||||||
|
deleteConnection.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
|
||||||
|
const onRemoved = await openWithRemoval(true)
|
||||||
|
await waitFor(() => expect(removeButton()).toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: tx('docsync.connection.remove') }))
|
||||||
|
await screen.findByText(tx('docsync.connection.confirmRemove'))
|
||||||
|
fireEvent.click(confirmRemoval())
|
||||||
|
|
||||||
|
await waitFor(() => expect(deleteConnection).toHaveBeenCalled())
|
||||||
|
expect(onRemoved).not.toHaveBeenCalled()
|
||||||
|
expect(await screen.findByText(tx('docsync.error.forbidden'))).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-DOCSYNC-CONNECT-030: a first connection has nothing to remove', async () => {
|
||||||
|
await openWithRemoval(false)
|
||||||
|
expect(removeButton()).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useId, useState } from 'react'
|
import { useId, useState } from 'react'
|
||||||
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
|
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
|
||||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
|
import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||||
|
import ConfirmDialog from '../../shared/ConfirmDialog'
|
||||||
import { Tooltip } from '../../shared/Tooltip'
|
import { Tooltip } from '../../shared/Tooltip'
|
||||||
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||||
@@ -22,11 +23,14 @@ export default function DocSyncConnectModal({
|
|||||||
sync,
|
sync,
|
||||||
onClose,
|
onClose,
|
||||||
onConnected,
|
onConnected,
|
||||||
|
onRemoved,
|
||||||
}: {
|
}: {
|
||||||
provider: DocSyncProvider
|
provider: DocSyncProvider
|
||||||
sync: ReturnType<typeof useDocSync>
|
sync: ReturnType<typeof useDocSync>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onConnected: (providerId: string) => void
|
onConnected: (providerId: string) => void
|
||||||
|
/** After the stored connection was removed from here; left out, the form offers no removal. */
|
||||||
|
onRemoved?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const existing = sync.connectionFor(provider.id)
|
const existing = sync.connectionFor(provider.id)
|
||||||
@@ -34,6 +38,11 @@ export default function DocSyncConnectModal({
|
|||||||
|
|
||||||
const form = useConnectForm(provider, existing, sync)
|
const form = useConnectForm(provider, existing, sync)
|
||||||
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
||||||
|
const [confirmRemove, setConfirmRemove] = useState(false)
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (existing && (await sync.removeConnection(existing.id))) onRemoved?.()
|
||||||
|
}
|
||||||
|
|
||||||
const test = async () => setVerdict(await form.probe())
|
const test = async () => setVerdict(await form.probe())
|
||||||
const labelId = useId()
|
const labelId = useId()
|
||||||
@@ -46,6 +55,7 @@ export default function DocSyncConnectModal({
|
|||||||
<DialogShell
|
<DialogShell
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
labelledBy={labelId}
|
labelledBy={labelId}
|
||||||
|
blocked={confirmRemove}
|
||||||
header={(
|
header={(
|
||||||
<DialogHeader
|
<DialogHeader
|
||||||
tile={<DialogTile>{Icon ? <Icon className="h-5 w-5 text-content" /> : <Plug size={20} strokeWidth={1.9} className="text-content-muted" />}</DialogTile>}
|
tile={<DialogTile>{Icon ? <Icon className="h-5 w-5 text-content" /> : <Plug size={20} strokeWidth={1.9} className="text-content-muted" />}</DialogTile>}
|
||||||
@@ -57,6 +67,13 @@ export default function DocSyncConnectModal({
|
|||||||
)}
|
)}
|
||||||
footer={(
|
footer={(
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
{existing && onRemoved && (
|
||||||
|
<DeleteButton
|
||||||
|
label={t('docsync.connection.remove')}
|
||||||
|
onClick={() => setConfirmRemove(true)}
|
||||||
|
disabled={sync.busy === 'connection'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Verdict verdict={verdict} busy={sync.busy === 'test'} failure={sync.error} />
|
<Verdict verdict={verdict} busy={sync.busy === 'test'} failure={sync.error} />
|
||||||
<FooterSpacer />
|
<FooterSpacer />
|
||||||
<DialogButton onClick={() => void test()} disabled={!form.complete || sync.busy === 'test'}>
|
<DialogButton onClick={() => void test()} disabled={!form.complete || sync.busy === 'test'}>
|
||||||
@@ -101,6 +118,15 @@ export default function DocSyncConnectModal({
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={confirmRemove}
|
||||||
|
onClose={() => setConfirmRemove(false)}
|
||||||
|
onConfirm={() => void remove()}
|
||||||
|
title={t('docsync.connection.remove')}
|
||||||
|
message={t('docsync.connection.confirmRemove')}
|
||||||
|
confirmLabel={t('docsync.connection.remove')}
|
||||||
|
/>
|
||||||
</DialogShell>
|
</DialogShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export default function DocSyncPanel({
|
|||||||
setSelected(prev => (sync.links.some(l => l.id === prev) ? prev : (sync.links[0]?.id ?? null)))
|
setSelected(prev => (sync.links.some(l => l.id === prev) ? prev : (sync.links[0]?.id ?? null)))
|
||||||
}, [sync.links])
|
}, [sync.links])
|
||||||
|
|
||||||
|
// The connection behind the folder picker, which the picker can send back to.
|
||||||
|
const scopeProvider = scopeFor ? sync.providers.find(p => p.id === scopeFor) : undefined
|
||||||
|
|
||||||
const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0)
|
const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0)
|
||||||
const labelId = useId()
|
const labelId = useId()
|
||||||
|
|
||||||
@@ -113,6 +116,7 @@ export default function DocSyncPanel({
|
|||||||
sync={sync}
|
sync={sync}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
onReconnect={activeProvider ? () => setReconnecting(activeProvider) : undefined}
|
onReconnect={activeProvider ? () => setReconnecting(activeProvider) : undefined}
|
||||||
|
onEditConnection={canManage && activeProvider ? () => setReconnecting(activeProvider) : undefined}
|
||||||
/>
|
/>
|
||||||
{/* Every action in this column reports here: a refused run, a
|
{/* Every action in this column reports here: a refused run, a
|
||||||
switch that flipped back, a conflict the server would not
|
switch that flipped back, a conflict the server would not
|
||||||
@@ -141,6 +145,7 @@ export default function DocSyncPanel({
|
|||||||
sync={sync}
|
sync={sync}
|
||||||
onClose={() => setConnecting(null)}
|
onClose={() => setConnecting(null)}
|
||||||
onConnected={id => { setConnecting(null); setScopeFor(id) }}
|
onConnected={id => { setConnecting(null); setScopeFor(id) }}
|
||||||
|
onRemoved={() => setConnecting(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -149,6 +154,7 @@ export default function DocSyncPanel({
|
|||||||
provider={reconnecting}
|
provider={reconnecting}
|
||||||
sync={sync}
|
sync={sync}
|
||||||
onClose={() => setReconnecting(null)}
|
onClose={() => setReconnecting(null)}
|
||||||
|
onRemoved={() => setReconnecting(null)}
|
||||||
onConnected={id => {
|
onConnected={id => {
|
||||||
setReconnecting(null)
|
setReconnecting(null)
|
||||||
// Run straight away, as binding does: the card still reports the
|
// Run straight away, as binding does: the card still reports the
|
||||||
@@ -168,6 +174,7 @@ export default function DocSyncPanel({
|
|||||||
sync={sync}
|
sync={sync}
|
||||||
onClose={() => setScopeFor(null)}
|
onClose={() => setScopeFor(null)}
|
||||||
onBound={() => setScopeFor(null)}
|
onBound={() => setScopeFor(null)}
|
||||||
|
onEditConnection={scopeProvider ? () => { setScopeFor(null); setConnecting(scopeProvider) } : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-023
|
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-024
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The folder picker of the document-sync dialog.
|
* The folder picker of the document-sync dialog.
|
||||||
@@ -84,9 +84,10 @@ function deferred<T>() {
|
|||||||
return { promise, resolve, reject }
|
return { promise, resolve, reject }
|
||||||
}
|
}
|
||||||
|
|
||||||
function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }: {
|
function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless', onEditConnection }: {
|
||||||
suggestedName?: string
|
suggestedName?: string
|
||||||
providerName?: string
|
providerName?: string
|
||||||
|
onEditConnection?: () => void
|
||||||
}) {
|
}) {
|
||||||
const sync = useDocSync(TRIP_ID, false)
|
const sync = useDocSync(TRIP_ID, false)
|
||||||
return (
|
return (
|
||||||
@@ -97,6 +98,7 @@ function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }
|
|||||||
sync={sync}
|
sync={sync}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onBound={onBound}
|
onBound={onBound}
|
||||||
|
onEditConnection={onEditConnection}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -381,3 +383,20 @@ describe('DocSyncScopeModal errors', () => {
|
|||||||
expect(createButton()).toBeEnabled()
|
expect(createButton()).toBeEnabled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('DocSyncScopeModal connection', () => {
|
||||||
|
it('FE-DOCSYNC-SCOPE-024: leads back to the stored connection before a folder is chosen', async () => {
|
||||||
|
const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] })
|
||||||
|
const onEditConnection = vi.fn()
|
||||||
|
const first = render(<Harness onEditConnection={onEditConnection} />)
|
||||||
|
await waitFor(() => expect(listScopes).toHaveBeenCalled())
|
||||||
|
|
||||||
|
fireEvent.click(editButton() as HTMLElement)
|
||||||
|
expect(onEditConnection).toHaveBeenCalledTimes(1)
|
||||||
|
expect(createLink).not.toHaveBeenCalled()
|
||||||
|
first.unmount()
|
||||||
|
|
||||||
|
render(<Harness />)
|
||||||
|
expect(editButton()).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useId, useMemo, useState } from 'react'
|
import { useId, useMemo, useState } from 'react'
|
||||||
import { FolderPlus, Loader2, Search } from 'lucide-react'
|
import { FolderPlus, KeyRound, Loader2, Search } from 'lucide-react'
|
||||||
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell'
|
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||||
import type { DocSyncConnection, useDocSync } from './useDocSync'
|
import type { DocSyncConnection, useDocSync } from './useDocSync'
|
||||||
@@ -22,6 +22,7 @@ export default function DocSyncScopeModal({
|
|||||||
sync,
|
sync,
|
||||||
onClose,
|
onClose,
|
||||||
onBound,
|
onBound,
|
||||||
|
onEditConnection,
|
||||||
}: {
|
}: {
|
||||||
connection: DocSyncConnection
|
connection: DocSyncConnection
|
||||||
providerName: string
|
providerName: string
|
||||||
@@ -30,6 +31,8 @@ export default function DocSyncScopeModal({
|
|||||||
sync: ReturnType<typeof useDocSync>
|
sync: ReturnType<typeof useDocSync>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onBound: () => void
|
onBound: () => void
|
||||||
|
/** Back to the stored connection, to change the login or remove it before choosing a folder. */
|
||||||
|
onEditConnection?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const labelId = useId()
|
const labelId = useId()
|
||||||
@@ -63,7 +66,19 @@ export default function DocSyncScopeModal({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
|
||||||
|
{onEditConnection && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditConnection}
|
||||||
|
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-edge px-2.5 py-1 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
<KeyRound size={13} />
|
||||||
|
{t('docsync.connection.edit')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-edge bg-surface-secondary p-3">
|
<div className="rounded-xl border border-edge bg-surface-secondary p-3">
|
||||||
<span className="mb-2 block text-body font-medium text-content">{t('docsync.scope.createTitle')}</span>
|
<span className="mb-2 block text-body font-medium text-content">{t('docsync.scope.createTitle')}</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-030
|
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-032
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The document-sync hook.
|
* The document-sync hook.
|
||||||
@@ -22,6 +22,7 @@ const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown)
|
|||||||
const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise<unknown> => ({}))
|
const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise<unknown> => ({}))
|
||||||
const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({}))
|
const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({}))
|
||||||
const listScopesApi = vi.fn(async (_tripId: number | string, _connectionId: number, _q?: string): Promise<unknown> => ({ scopes: [] }))
|
const listScopesApi = vi.fn(async (_tripId: number | string, _connectionId: number, _q?: string): Promise<unknown> => ({ scopes: [] }))
|
||||||
|
const deleteConnectionApi = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
|
||||||
const resolveApi = vi.fn(async (_tripId: number | string, _itemId: number, _keep: string): Promise<unknown> => ({ success: true }))
|
const resolveApi = vi.fn(async (_tripId: number | string, _itemId: number, _keep: string): Promise<unknown> => ({ success: true }))
|
||||||
|
|
||||||
vi.mock('../../../api/client', async (importOriginal) => {
|
vi.mock('../../../api/client', async (importOriginal) => {
|
||||||
@@ -40,6 +41,7 @@ vi.mock('../../../api/client', async (importOriginal) => {
|
|||||||
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full),
|
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full),
|
||||||
listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopesApi(tripId, connectionId, q),
|
listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopesApi(tripId, connectionId, q),
|
||||||
resolve: (tripId: number | string, itemId: number, keep: string) => resolveApi(tripId, itemId, keep),
|
resolve: (tripId: number | string, itemId: number, keep: string) => resolveApi(tripId, itemId, keep),
|
||||||
|
deleteConnection: (tripId: number | string, id: number) => deleteConnectionApi(tripId, id),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -126,6 +128,7 @@ beforeEach(() => {
|
|||||||
syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
|
syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
|
||||||
listScopesApi.mockResolvedValue({ scopes: [] })
|
listScopesApi.mockResolvedValue({ scopes: [] })
|
||||||
resolveApi.mockResolvedValue({ success: true })
|
resolveApi.mockResolvedValue({ success: true })
|
||||||
|
deleteConnectionApi.mockResolvedValue({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useDocSync initial load', () => {
|
describe('useDocSync initial load', () => {
|
||||||
@@ -471,6 +474,38 @@ describe('useDocSync resolveConflict', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('useDocSync removeConnection', () => {
|
||||||
|
it('FE-DOCSYNC-HOOK-031: forgets the connection and reloads, so its bindings leave the screen too', async () => {
|
||||||
|
const { result } = await mountLoaded()
|
||||||
|
expect(result.current.links).toHaveLength(1)
|
||||||
|
// The server drops the bindings with the connection (ON DELETE CASCADE).
|
||||||
|
listConnectionsApi.mockResolvedValue([])
|
||||||
|
listLinksApi.mockResolvedValue([])
|
||||||
|
|
||||||
|
let ok: boolean | undefined
|
||||||
|
await act(async () => { ok = await result.current.removeConnection(9) })
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(deleteConnectionApi).toHaveBeenCalledWith(TRIP, 9)
|
||||||
|
expect(result.current.connectionFor('nextcloud')).toBeNull()
|
||||||
|
expect(result.current.links).toEqual([])
|
||||||
|
expect(result.current.busy).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-DOCSYNC-HOOK-032: a refused removal keeps everything and says why', async () => {
|
||||||
|
deleteConnectionApi.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
|
||||||
|
const { result } = await mountLoaded()
|
||||||
|
|
||||||
|
let ok: boolean | undefined
|
||||||
|
await act(async () => { ok = await result.current.removeConnection(9) })
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(result.current.error).toBe('forbidden')
|
||||||
|
expect(result.current.connectionFor('nextcloud')).not.toBeNull()
|
||||||
|
expect(result.current.busy).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('needsReauth', () => {
|
describe('needsReauth', () => {
|
||||||
it('FE-DOCSYNC-HOOK-030: reads a refused credential off either the state or the error code', () => {
|
it('FE-DOCSYNC-HOOK-030: reads a refused credential off either the state or the error code', () => {
|
||||||
expect(needsReauth({ lastSyncState: 'needs_reauth', lastSyncError: null })).toBe(true)
|
expect(needsReauth({ lastSyncState: 'needs_reauth', lastSyncError: null })).toBe(true)
|
||||||
|
|||||||
@@ -264,6 +264,27 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
|
|||||||
}
|
}
|
||||||
}, [tripId, load])
|
}, [tripId, load])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget a stored connection, and with it every binding of this trip that
|
||||||
|
* runs through it. The documents stay on both sides; what goes is the
|
||||||
|
* address and the login TREK kept for the store, which is the one thing a
|
||||||
|
* person changing accounts or folders had no way to take back.
|
||||||
|
*/
|
||||||
|
const removeConnection = useCallback(async (connectionId: number) => {
|
||||||
|
setBusy('connection')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await docsyncApi.deleteConnection(tripId, connectionId)
|
||||||
|
await load()
|
||||||
|
return true
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(readError(e))
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}, [tripId, load])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Probe without saving. Always resolves to a verdict object: the route
|
* Probe without saving. Always resolves to a verdict object: the route
|
||||||
* answers 200 even for an unreachable instance, because a form showing a typo
|
* answers 200 even for an unreachable instance, because a form showing a typo
|
||||||
@@ -427,7 +448,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
|
|||||||
connections, links, itemCounts,
|
connections, links, itemCounts,
|
||||||
loading, busy, error,
|
loading, busy, error,
|
||||||
connectionFor, load,
|
connectionFor, load,
|
||||||
saveConnection, testConnection,
|
saveConnection, testConnection, removeConnection,
|
||||||
loadScopes, createScope,
|
loadScopes, createScope,
|
||||||
createLink, updateLink, removeLink, syncNow, resolveConflict,
|
createLink, updateLink, removeLink, syncNow, resolveConflict,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,6 +485,17 @@ describe('KategorieGruppe — inline add item', () => {
|
|||||||
expect(onAddItem).not.toHaveBeenCalled()
|
expect(onAddItem).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-W5CAT-028a: Enter on the first item typed into the placeholder row opens the add field (#2825)', async () => {
|
||||||
|
server.use(http.put('/api/trips/1/packing/1', () => HttpResponse.json({ item: buildPackingItem({ id: 1, name: 'Tent', category: 'Gear' }) })))
|
||||||
|
setup({ items: [buildPackingItem({ id: 1, name: '...', category: 'Gear' })] })
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('...'))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
|
||||||
|
fireEvent.keyDown(screen.getByPlaceholderText('...'), { key: 'Enter' })
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByPlaceholderText('Item name...')).toHaveFocus())
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-W5CAT-028: the confirm button adds the item, and stays inert while empty', () => {
|
it('FE-W5CAT-028: the confirm button adds the item, and stays inert while empty', () => {
|
||||||
const onAddItem = vi.fn(async () => {})
|
const onAddItem = vi.fn(async () => {})
|
||||||
const { container } = setup({ onAddItem })
|
const { container } = setup({ onAddItem })
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
|||||||
const [showAddItem, setShowAddItem] = useState(false)
|
const [showAddItem, setShowAddItem] = useState(false)
|
||||||
const [newItemName, setNewItemName] = useState('')
|
const [newItemName, setNewItemName] = useState('')
|
||||||
const addItemRef = useRef<HTMLInputElement>(null)
|
const addItemRef = useRef<HTMLInputElement>(null)
|
||||||
|
const openAddItem = () => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }
|
||||||
const menuBtnRef = useRef<HTMLButtonElement>(null)
|
const menuBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
const assigneeDropdownRef = useRef<HTMLDivElement>(null)
|
const assigneeDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const { togglePackingItem } = useTripStore()
|
const { togglePackingItem } = useTripStore()
|
||||||
@@ -292,7 +293,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
|||||||
const contributions = contribFor(item.id)
|
const contributions = contribFor(item.id)
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={item.id}>
|
<React.Fragment key={item.id}>
|
||||||
<ArtikelZeile item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} divider={index > 0}
|
<ArtikelZeile item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} onPlaceholderFilled={openAddItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} divider={index > 0}
|
||||||
tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave}
|
tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave}
|
||||||
drag={canEdit && reorderable ? {
|
drag={canEdit && reorderable ? {
|
||||||
isDragging: dragId === item.id,
|
isDragging: dragId === item.id,
|
||||||
@@ -343,7 +344,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
|||||||
// A row of its own on a faint tint, so it closes the card instead
|
// A row of its own on a faint tint, so it closes the card instead
|
||||||
// of trailing off: a dashed box in the checkbox column (past the drag
|
// of trailing off: a dashed box in the checkbox column (past the drag
|
||||||
// grip's width) and the label where an item's name sits.
|
// grip's width) and the label where an item's name sits.
|
||||||
<button type="button" onClick={() => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
<button type="button" onClick={openAddItem}
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 9, width: 'calc(100% - 8px)', margin: '6px 4px 0', paddingBlock: 8, paddingInlineEnd: 10, paddingInlineStart: 28, borderRadius: 10, border: 'none', background: 'var(--bg-secondary)', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'start' }}
|
style={{ display: 'flex', alignItems: 'center', gap: 9, width: 'calc(100% - 8px)', margin: '6px 4px 0', paddingBlock: 8, paddingInlineEnd: 10, paddingInlineStart: 28, borderRadius: 10, border: 'none', background: 'var(--bg-secondary)', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'start' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
|||||||
@@ -257,6 +257,23 @@ describe('ArtikelZeile — renaming', () => {
|
|||||||
await waitFor(() => expect(screen.getByText('...')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('...')).toBeInTheDocument())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-W5ROW-008b: Enter on a filled placeholder row hands the typing on, a blur does not (#2825)', async () => {
|
||||||
|
server.use(http.put('/api/trips/1/packing/1', () => HttpResponse.json({ item: buildPackingItem({ id: 1, name: 'Tent' }) })))
|
||||||
|
const onPlaceholderFilled = vi.fn()
|
||||||
|
setup({ item: buildPackingItem({ id: 1, name: '...', category: 'Gear' }), onPlaceholderFilled })
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('...'))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
|
||||||
|
fireEvent.blur(screen.getByPlaceholderText('...'))
|
||||||
|
await waitFor(() => expect(screen.queryByPlaceholderText('...')).toBeNull())
|
||||||
|
expect(onPlaceholderFilled).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('...'))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
|
||||||
|
fireEvent.keyDown(screen.getByPlaceholderText('...'), { key: 'Enter' })
|
||||||
|
await waitFor(() => expect(onPlaceholderFilled).toHaveBeenCalledTimes(1))
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-W5ROW-009: a failing rename surfaces a save error', async () => {
|
it('FE-W5ROW-009: a failing rename surfaces a save error', async () => {
|
||||||
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
||||||
setup()
|
setup()
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface ArtikelZeileProps {
|
|||||||
categories: string[]
|
categories: string[]
|
||||||
onCategoryChange: () => void
|
onCategoryChange: () => void
|
||||||
onDelete?: (item: PackingItem) => Promise<void>
|
onDelete?: (item: PackingItem) => Promise<void>
|
||||||
|
/** Enter on the filled '...' row, so the category's add field can take over the typing (#2825). */
|
||||||
|
onPlaceholderFilled?: () => void
|
||||||
bagTrackingEnabled?: boolean
|
bagTrackingEnabled?: boolean
|
||||||
bags?: PackingBag[]
|
bags?: PackingBag[]
|
||||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||||
@@ -49,7 +51,7 @@ interface ArtikelZeileProps {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, divider = false, drag }: ArtikelZeileProps) {
|
export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, onPlaceholderFilled, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, divider = false, drag }: ArtikelZeileProps) {
|
||||||
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
||||||
@@ -80,9 +82,12 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
|||||||
|
|
||||||
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
|
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
|
||||||
|
|
||||||
const handleSaveName = async () => {
|
const handleSaveName = async (viaEnter = false) => {
|
||||||
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
|
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
|
||||||
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
|
try {
|
||||||
|
await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false)
|
||||||
|
if (viaEnter && isPlaceholder) onPlaceholderFilled?.()
|
||||||
|
}
|
||||||
catch { toast.error(t('packing.toast.saveError')) }
|
catch { toast.error(t('packing.toast.saveError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,8 +193,8 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
|||||||
type="text" value={editName} autoFocus
|
type="text" value={editName} autoFocus
|
||||||
placeholder={isPlaceholder ? '...' : undefined}
|
placeholder={isPlaceholder ? '...' : undefined}
|
||||||
onChange={e => setEditName(e.target.value)}
|
onChange={e => setEditName(e.target.value)}
|
||||||
onBlur={handleSaveName}
|
onBlur={() => void handleSaveName()}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') void handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
|
onKeyDown={e => { if (e.key === 'Enter') void handleSaveName(true); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
|
||||||
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
|
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
|
||||||
/>
|
/>
|
||||||
) : canEdit ? (
|
) : canEdit ? (
|
||||||
|
|||||||
@@ -803,6 +803,34 @@ describe('useRoadtripRoutes', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-ROUTES-052: a join this device routed before is not paced like a request (#2794)', async () => {
|
||||||
|
// A join kept its request slot whatever answered it, so a month of connected days
|
||||||
|
// waited half a minute on every open with every road already on the device. The
|
||||||
|
// clock is held still, so every answer reads as one from the cache; a join that
|
||||||
|
// still waited its turn would wait 1.1 s of real time, past what waitFor gives it.
|
||||||
|
const clock = vi.spyOn(performance, 'now').mockImplementation(() => 10_000)
|
||||||
|
try {
|
||||||
|
const MUNICH: [number, number] = [48.1374, 11.5755]
|
||||||
|
const INNSBRUCK: [number, number] = [47.2692, 11.4041]
|
||||||
|
const BOZEN: [number, number] = [46.4983, 11.3548]
|
||||||
|
setting({ roadtrip_connect_days: true })
|
||||||
|
const daysList = [day(1, 1), day(2, 2), day(3, 3)]
|
||||||
|
const assignments = {
|
||||||
|
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
|
||||||
|
...map(2, [{ id: 3, at: BERLIN, time: '09:00' }, { id: 4, at: MUNICH }]),
|
||||||
|
...map(3, [{ id: 5, at: INNSBRUCK, time: '09:00' }, { id: 6, at: BOZEN }]),
|
||||||
|
} as AssignmentsMap
|
||||||
|
renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1)
|
||||||
|
expect(askedFor(MUNICH, INNSBRUCK)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
clock.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-ROADTRIP-ROUTES-051: a join OSRM answered in the engine’s place is drawn, flagged, and asked for again on request', async () => {
|
it('FE-ROADTRIP-ROUTES-051: a join OSRM answered in the engine’s place is drawn, flagged, and asked for again on request', async () => {
|
||||||
// Filed like any answer, it stood in for the weighed road until the page was loaded
|
// Filed like any answer, it stood in for the weighed road until the page was loaded
|
||||||
// again, and a picker on it read the stand-in line as the road already taken.
|
// again, and a picker on it read the stand-in line as the road already taken.
|
||||||
|
|||||||
@@ -692,11 +692,19 @@ export function useRoadtripRoutes(
|
|||||||
* for both, so a choice being checked cannot fire into the gap a seam is keeping.
|
* for both, so a choice being checked cannot fire into the gap a seam is keeping.
|
||||||
*/
|
*/
|
||||||
const lastSpacedRequestAt = useRef(0)
|
const lastSpacedRequestAt = useRef(0)
|
||||||
/** Waits until the gap since the last such request is kept, then claims the next slot. */
|
/**
|
||||||
const spacedRequest = useCallback(async (signal: AbortSignal): Promise<void> => {
|
* Waits until the gap since the last such request is kept, then claims the next slot.
|
||||||
|
* Hands back what gives the slot up again, for a request the cache answered.
|
||||||
|
*/
|
||||||
|
const spacedRequest = useCallback(async (signal: AbortSignal): Promise<() => void> => {
|
||||||
const since = performance.now() - lastSpacedRequestAt.current
|
const since = performance.now() - lastSpacedRequestAt.current
|
||||||
if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, signal)
|
if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, signal)
|
||||||
if (!signal.aborted) lastSpacedRequestAt.current = performance.now()
|
const before = lastSpacedRequestAt.current
|
||||||
|
const claimed = performance.now()
|
||||||
|
if (!signal.aborted) lastSpacedRequestAt.current = claimed
|
||||||
|
return () => {
|
||||||
|
if (lastSpacedRequestAt.current === claimed) lastSpacedRequestAt.current = before
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -727,7 +735,7 @@ export function useRoadtripRoutes(
|
|||||||
// shared routing hosts answered the tail with 429, and a refused seam
|
// shared routing hosts answered the tail with 429, and a refused seam
|
||||||
// writes no state, so nothing changed to make the effect try again: the
|
// writes no state, so nothing changed to make the effect try again: the
|
||||||
// map drew the trip in pieces and the totals came back short.
|
// map drew the trip in pieces and the totals came back short.
|
||||||
await spacedRequest(controller.signal)
|
const release = await spacedRequest(controller.signal)
|
||||||
if (controller.signal.aborted) return
|
if (controller.signal.aborted) return
|
||||||
try {
|
try {
|
||||||
// The via points on this seam, threaded in the same way the day runs thread
|
// The via points on this seam, threaded in the same way the day runs thread
|
||||||
@@ -735,6 +743,7 @@ export function useRoadtripRoutes(
|
|||||||
// shape: it is asked for on its own, so the points the traveller dropped on it
|
// shape: it is asked for on its own, so the points the traveller dropped on it
|
||||||
// never reached the router and dragging one did visibly nothing.
|
// never reached the router and dragging one did visibly nothing.
|
||||||
const shaping = viasOnLeg(seam.from, seam.to, viasByDay[seam.from.ownerDayId] ?? [])
|
const shaping = viasOnLeg(seam.from, seam.to, viasByDay[seam.from.ownerDayId] ?? [])
|
||||||
|
const startedAt = performance.now()
|
||||||
const r = await calculateRouteWithLegs(
|
const r = await calculateRouteWithLegs(
|
||||||
[
|
[
|
||||||
{ lat: seam.from.lat, lng: seam.from.lng },
|
{ lat: seam.from.lat, lng: seam.from.lng },
|
||||||
@@ -743,6 +752,10 @@ export function useRoadtripRoutes(
|
|||||||
],
|
],
|
||||||
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: seam.dayId, avoid },
|
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: seam.dayId, avoid },
|
||||||
)
|
)
|
||||||
|
// A seam this device routed before came back from its cache and went nowhere, so
|
||||||
|
// its slot goes back before the store below re-runs this effect. Holding it made a
|
||||||
|
// trip of connected days wait a second per join on every open (#2794).
|
||||||
|
if (performance.now() - startedAt <= CACHE_HIT_MS) release()
|
||||||
if (controller.signal.aborted) return
|
if (controller.signal.aborted) return
|
||||||
// One leg per waypoint PAIR, so a shaped seam comes back in pieces and the rail
|
// One leg per waypoint PAIR, so a shaped seam comes back in pieces and the rail
|
||||||
// wants the whole drive: merged here, exactly as a day run folds its via legs
|
// wants the whole drive: merged here, exactly as a day run folds its via legs
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ describe('aiConnectModel', () => {
|
|||||||
expect(connectErrorKey(new Error('boom'))).toBe('assistant.error.generic');
|
expect(connectErrorKey(new Error('boom'))).toBe('assistant.error.generic');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-AI-CONNECT-MODEL-002b: tells an own server that answered without a model list from one that is down (#2808)', () => {
|
||||||
|
const noList = { response: { data: { code: 'provider_unavailable', reason: 'no_model_list' } } };
|
||||||
|
expect(connectErrorKey(noList)).toBe('assistant.connect.noModelList');
|
||||||
|
expect(connectErrorKey({ response: { data: { code: 'provider_unavailable', reason: 'other' } } })).toBe(
|
||||||
|
'assistant.error.providerDown'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-AI-CONNECT-MODEL-003: offers the way back into a broken connection, none for a working one', () => {
|
it('FE-AI-CONNECT-MODEL-003: offers the way back into a broken connection, none for a working one', () => {
|
||||||
expect(repairModeOf(VIEW)).toBeNull();
|
expect(repairModeOf(VIEW)).toBeNull();
|
||||||
expect(repairModeOf({ ...VIEW, status: 'error' })).toBe('key');
|
expect(repairModeOf({ ...VIEW, status: 'error' })).toBe('key');
|
||||||
|
|||||||
@@ -33,9 +33,20 @@ const CONNECT_ERRORS: Record<string, { messageKey: string }> = {
|
|||||||
offline: { messageKey: 'assistant.error.offline' },
|
offline: { messageKey: 'assistant.error.offline' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The line a failed connect shows. Takes `{provider}` where the line names one. */
|
/** What a refused connect says for the `reason` it carries next to its code, ahead of the code's line. */
|
||||||
|
const CONNECT_REASONS: Record<string, { messageKey: string }> = {
|
||||||
|
no_model_list: { messageKey: 'assistant.connect.noModelList' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line a failed connect shows. Takes `{provider}` where the line names one.
|
||||||
|
* An own server that answered without a model list is up, so it gets its own
|
||||||
|
* line about the address instead of "not responding" (#2808).
|
||||||
|
*/
|
||||||
export function connectErrorKey(err: unknown): string {
|
export function connectErrorKey(err: unknown): string {
|
||||||
return CONNECT_ERRORS[refusalCode(err) ?? '']?.messageKey ?? 'assistant.error.generic';
|
const reason = (err as { response?: { data?: { reason?: unknown } } } | null)?.response?.data?.reason;
|
||||||
|
const byReason = typeof reason === 'string' ? CONNECT_REASONS[reason] : undefined;
|
||||||
|
return byReason?.messageKey ?? CONNECT_ERRORS[refusalCode(err) ?? '']?.messageKey ?? 'assistant.error.generic';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How each connection state reads on its pill. */
|
/** How each connection state reads on its pill. */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-003
|
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-004
|
||||||
// The GPX preview as ACT-01 uses it: preset to the tour's type, a city walk
|
// The GPX preview as ACT-01 uses it: preset to the tour's type, a city walk
|
||||||
// imported as one, and when a track was recorded shown where it carried times.
|
// imported as one, and when a track was recorded shown where it carried times.
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
@@ -47,6 +47,13 @@ describe('TourGpxPreviewDialog (ACT-01)', () => {
|
|||||||
expect(recorded[0]).toHaveTextContent('50 min');
|
expect(recorded[0]).toHaveTextContent('50 min');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-GPX-PREVIEW-004: a track taken over from before Tours reads as one, not as a duplicate (#2810)', () => {
|
||||||
|
render(<TourGpxPreviewDialog gpxImport={gpxImport([track({ adopts_track: true })])} />);
|
||||||
|
expect(screen.getByTestId('gpx-preview-track')).not.toHaveAttribute('data-duplicate');
|
||||||
|
expect(screen.getByText(/tours\.import\.previewAdopts|becomes a tour/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/tours\.import\.previewDuplicate|Already in the trip/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-GPX-PREVIEW-002: imports as the preset type, a paddle tour with its boat and water', () => {
|
it('FE-GPX-PREVIEW-002: imports as the preset type, a paddle tour with its boat and water', () => {
|
||||||
const current = gpxImport([track()]);
|
const current = gpxImport([track()]);
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ function PreviewTrackRow({
|
|||||||
{track.name || name}
|
{track.name || name}
|
||||||
</span>
|
</span>
|
||||||
{track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>}
|
{track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>}
|
||||||
|
{track.adopts_track && <SoftPill>{t('tours.import.previewAdopts')}</SoftPill>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1.5 ps-8" style={fs(11)}>
|
<div className="flex flex-wrap items-center gap-1.5 ps-8" style={fs(11)}>
|
||||||
<span className={BADGE}>{formatDistance(track.distance_km, unit)}</span>
|
<span className={BADGE}>{formatDistance(track.distance_km, unit)}</span>
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ export default function MAdminPluginsPanel() {
|
|||||||
{bypass.copy && (
|
{bypass.copy && (
|
||||||
<MConfirmSheet open onClose={bypass.dismiss} onConfirm={bypass.copy.confirm ? bypass.confirm : undefined}
|
<MConfirmSheet open onClose={bypass.dismiss} onConfirm={bypass.copy.confirm ? bypass.confirm : undefined}
|
||||||
title={bypass.copy.title} message={bypass.copy.body}
|
title={bypass.copy.title} message={bypass.copy.body}
|
||||||
confirmLabel={t('admin.plugins.installAnyway')} cancelLabel={bypass.copy.confirm ? t('common.cancel') : t('common.ok')} danger />
|
confirmLabel={bypass.copy.confirmLabel} cancelLabel={bypass.copy.confirm ? t('common.cancel') : t('common.ok')} danger />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Row ⋯ action sheet */}
|
{/* Row ⋯ action sheet */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-024
|
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-027
|
||||||
import type { ComponentProps } from 'react'
|
import type { ComponentProps } from 'react'
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { render, screen, fireEvent, waitFor, within } from '../../../../../tests/helpers/render'
|
import { render, screen, fireEvent, waitFor, within } from '../../../../../tests/helpers/render'
|
||||||
@@ -137,6 +137,12 @@ beforeEach(() => {
|
|||||||
vi.spyOn(docsyncApi, 'createLink').mockResolvedValue({})
|
vi.spyOn(docsyncApi, 'createLink').mockResolvedValue({})
|
||||||
vi.spyOn(docsyncApi, 'items').mockResolvedValue([])
|
vi.spyOn(docsyncApi, 'items').mockResolvedValue([])
|
||||||
vi.spyOn(docsyncApi, 'resolve').mockResolvedValue({ success: true })
|
vi.spyOn(docsyncApi, 'resolve').mockResolvedValue({ success: true })
|
||||||
|
vi.spyOn(docsyncApi, 'deleteConnection').mockImplementation(async (_tripId, id) => {
|
||||||
|
// The bindings go with their connection, as ON DELETE CASCADE has it.
|
||||||
|
connections = connections.filter(c => c.id !== id)
|
||||||
|
links = links.filter(l => l.connectionId !== id)
|
||||||
|
return { success: true }
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/** The status route with a number of conflicts on it, holdings as usual. */
|
/** The status route with a number of conflicts on it, holdings as usual. */
|
||||||
@@ -528,3 +534,45 @@ describe('MDocSyncSheet: what a member may do', () => {
|
|||||||
expect(screen.queryByRole('button', { name: /Papra/ })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: /Papra/ })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('MDocSyncSheet: the stored connection', () => {
|
||||||
|
const removeButtons = () => screen.getAllByRole('button', { name: 'Remove connection' })
|
||||||
|
|
||||||
|
it('FE-DOCSYNC-MOBILE-025: a binding opens its connection at any time, and removing it asks first', async () => {
|
||||||
|
renderSheet()
|
||||||
|
await openDetail()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edit connection' }))
|
||||||
|
expect(await screen.findByPlaceholderText('https://paperless.example')).toHaveValue('https://paperless.example')
|
||||||
|
|
||||||
|
fireEvent.click(removeButtons()[0])
|
||||||
|
expect(docsyncApi.deleteConnection).not.toHaveBeenCalled()
|
||||||
|
expect(await screen.findByText(/TREK forgets the address and login/)).toBeInTheDocument()
|
||||||
|
// The confirmation sheet opens over the form, so its button is the last one of that name.
|
||||||
|
const named = removeButtons()
|
||||||
|
fireEvent.click(named[named.length - 1])
|
||||||
|
|
||||||
|
await waitFor(() => expect(docsyncApi.deleteConnection).toHaveBeenCalledWith(TRIP_ID, 5))
|
||||||
|
// Back at the list, with Paperless offered again from scratch.
|
||||||
|
expect(await screen.findByText('Connect a provider')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-DOCSYNC-MOBILE-026: a stored connection without a binding can be edited from the folder picker', async () => {
|
||||||
|
renderSheet()
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Nextcloud/ }))
|
||||||
|
await screen.findByText('Photos')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edit connection' }))
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: 'Remove connection' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-DOCSYNC-MOBILE-027: a member sees no way to the connection', async () => {
|
||||||
|
renderSheet({ canManage: false })
|
||||||
|
await openDetail()
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: 'Edit connection' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ArrowLeft, ArrowRight, Check, ChevronLeft, ChevronRight, FolderPlus, FolderSync, KeyRound, Link2Off,
|
ArrowLeft, ArrowRight, Check, ChevronLeft, ChevronRight, FolderPlus, FolderSync, KeyRound, Link2Off,
|
||||||
Loader2, Plus, RefreshCw,
|
Loader2, Plus, RefreshCw, Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import MSheet from '../../../components/MSheet'
|
import MSheet from '../../../components/MSheet'
|
||||||
import MIconBtn from '../../../components/MIconBtn'
|
import MIconBtn from '../../../components/MIconBtn'
|
||||||
@@ -149,6 +149,7 @@ export default function MDocSyncSheet({
|
|||||||
provider={pending}
|
provider={pending}
|
||||||
sync={sync}
|
sync={sync}
|
||||||
onDone={connected}
|
onDone={connected}
|
||||||
|
onRemoved={() => { setReconnectFor(null); setPending(null); setView('list') }}
|
||||||
/>
|
/>
|
||||||
) : view === 'scope' && pending && sync.connectionFor(pending.id) ? (
|
) : view === 'scope' && pending && sync.connectionFor(pending.id) ? (
|
||||||
<ScopeView
|
<ScopeView
|
||||||
@@ -156,6 +157,7 @@ export default function MDocSyncSheet({
|
|||||||
suggestedName={slugFor(tripTitle, tripId)}
|
suggestedName={slugFor(tripTitle, tripId)}
|
||||||
sync={sync}
|
sync={sync}
|
||||||
onBound={() => setView('list')}
|
onBound={() => setView('list')}
|
||||||
|
onEditConnection={() => setView('connect')}
|
||||||
/>
|
/>
|
||||||
) : view === 'detail' && link ? (
|
) : view === 'detail' && link ? (
|
||||||
<DetailView
|
<DetailView
|
||||||
@@ -417,6 +419,19 @@ function DetailView({
|
|||||||
</button>
|
</button>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
|
{onReconnect && (
|
||||||
|
<SettingRow label={t('docsync.connection.title')} hint={t('docsync.connection.hint')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReconnect}
|
||||||
|
className="flex items-center gap-1.5 rounded-full bg-[color:var(--m-ic)] px-3 py-[6px] font-geist text-caption font-bold text-m-ink"
|
||||||
|
>
|
||||||
|
<KeyRound size={12} strokeWidth={2.2} />
|
||||||
|
{t('docsync.connection.edit')}
|
||||||
|
</button>
|
||||||
|
</SettingRow>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onUnlink}
|
onClick={onUnlink}
|
||||||
@@ -443,14 +458,18 @@ function ConnectView({
|
|||||||
provider,
|
provider,
|
||||||
sync,
|
sync,
|
||||||
onDone,
|
onDone,
|
||||||
|
onRemoved,
|
||||||
}: {
|
}: {
|
||||||
provider: DocSyncProvider
|
provider: DocSyncProvider
|
||||||
sync: ReturnType<typeof useDocSync>
|
sync: ReturnType<typeof useDocSync>
|
||||||
onDone: () => void
|
onDone: () => void
|
||||||
|
onRemoved: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const form = useConnectForm(provider, sync.connectionFor(provider.id), sync)
|
const existing = sync.connectionFor(provider.id)
|
||||||
|
const form = useConnectForm(provider, existing, sync)
|
||||||
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
||||||
|
const [confirmRemove, setConfirmRemove] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 flex flex-col gap-3">
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
@@ -534,6 +553,32 @@ function ConnectView({
|
|||||||
{sync.busy === 'save' ? <Loader2 size={15} className="mx-auto animate-spin" /> : t('common.save')}
|
{sync.busy === 'save' ? <Loader2 size={15} className="mx-auto animate-spin" /> : t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{existing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmRemove(true)}
|
||||||
|
disabled={sync.busy === 'connection'}
|
||||||
|
className="flex h-11 w-full items-center justify-center gap-2 rounded-2xl border border-[color:var(--m-rowbr)] text-body font-bold text-[color:var(--m-st-danger)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} strokeWidth={2.2} />
|
||||||
|
{t('docsync.connection.remove')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MConfirmSheet
|
||||||
|
open={confirmRemove}
|
||||||
|
onClose={() => setConfirmRemove(false)}
|
||||||
|
title={t('docsync.connection.remove')}
|
||||||
|
message={t('docsync.connection.confirmRemove')}
|
||||||
|
confirmLabel={t('docsync.connection.remove')}
|
||||||
|
cancelLabel={t('common.cancel')}
|
||||||
|
danger
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmRemove(false)
|
||||||
|
if (existing) void sync.removeConnection(existing.id).then(ok => { if (ok) onRemoved() })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -544,11 +589,14 @@ function ScopeView({
|
|||||||
suggestedName,
|
suggestedName,
|
||||||
sync,
|
sync,
|
||||||
onBound,
|
onBound,
|
||||||
|
onEditConnection,
|
||||||
}: {
|
}: {
|
||||||
connectionId: number
|
connectionId: number
|
||||||
suggestedName: string
|
suggestedName: string
|
||||||
sync: ReturnType<typeof useDocSync>
|
sync: ReturnType<typeof useDocSync>
|
||||||
onBound: () => void
|
onBound: () => void
|
||||||
|
/** Back to the stored connection, to change the login or remove it before choosing a folder. */
|
||||||
|
onEditConnection: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { scopes, error, name, setName, working, bind, createAndBind } = useScopePicker({
|
const { scopes, error, name, setName, working, bind, createAndBind } = useScopePicker({
|
||||||
@@ -562,6 +610,15 @@ function ScopeView({
|
|||||||
<div className="mt-3 flex flex-col gap-3">
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
<p className="font-geist text-[0.71875rem] leading-snug text-m-muted">{t('docsync.scope.intro')}</p>
|
<p className="font-geist text-[0.71875rem] leading-snug text-m-muted">{t('docsync.scope.intro')}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditConnection}
|
||||||
|
className="flex h-9 items-center justify-center gap-1.5 self-start rounded-full bg-[color:var(--m-ic)] px-3 font-geist text-caption font-bold text-m-ink"
|
||||||
|
>
|
||||||
|
<KeyRound size={12} strokeWidth={2.2} />
|
||||||
|
{t('docsync.connection.edit')}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
|
|||||||
@@ -359,12 +359,22 @@ describe('MAdminPluginsPanel — Discover', () => {
|
|||||||
await waitFor(() => expect(toastMessages()).toContain('Installed'));
|
await waitFor(() => expect(toastMessages()).toContain('Installed'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-MOB-PLUGP-018: an entry no published version fits gets a dead, explained button', async () => {
|
it('FE-MOB-PLUGP-018: an entry no published version fits installs only after the admin accepts the risk', async () => {
|
||||||
|
let body: unknown = null;
|
||||||
await openDiscover([registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })]);
|
await openDiscover([registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })]);
|
||||||
|
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null });
|
||||||
|
}));
|
||||||
|
|
||||||
const btn = await screen.findByRole('button', { name: 'Incompatible' });
|
const btn = await screen.findByRole('button', { name: /^Install$/ });
|
||||||
expect(btn).toBeDisabled();
|
|
||||||
expect(btn).toHaveAttribute('title', 'Needs TREK >=4.0.0 — this server runs 3.3.0');
|
expect(btn).toHaveAttribute('title', 'Needs TREK >=4.0.0 — this server runs 3.3.0');
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
const sheet = await screen.findByRole('dialog', { name: 'Untested on this TREK' });
|
||||||
|
expect(body).toBeNull();
|
||||||
|
fireEvent.click(within(sheet).getByRole('button', { name: 'Install anyway' }));
|
||||||
|
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', acceptTrekRange: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-MOB-PLUGP-019: when only an older release fits, that version is offered and installed', async () => {
|
it('FE-MOB-PLUGP-019: when only an older release fits, that version is offered and installed', async () => {
|
||||||
@@ -1806,7 +1816,7 @@ describe('MAdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RAN
|
|||||||
expect(await screen.findByText('Version checks off')).toBeInTheDocument();
|
expect(await screen.findByText('Version checks off')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-MOB-PLUGP-BYPASS-002: offers "Install anyway" and asks before installing', async () => {
|
it('FE-MOB-PLUGP-BYPASS-002: offers Install and asks before installing', async () => {
|
||||||
let body: unknown = null;
|
let body: unknown = null;
|
||||||
mockPanel([], [incompatibleEntry()], { ignoreTrekRange: true });
|
mockPanel([], [incompatibleEntry()], { ignoreTrekRange: true });
|
||||||
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
||||||
@@ -1816,7 +1826,7 @@ describe('MAdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RAN
|
|||||||
render(<MAdminPluginsPanel />);
|
render(<MAdminPluginsPanel />);
|
||||||
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
||||||
|
|
||||||
const btn = await screen.findByRole('button', { name: 'Install anyway' });
|
const btn = await screen.findByRole('button', { name: /^Install$/ });
|
||||||
expect(btn).toBeEnabled();
|
expect(btn).toBeEnabled();
|
||||||
fireEvent.click(btn);
|
fireEvent.click(btn);
|
||||||
|
|
||||||
@@ -1834,7 +1844,7 @@ describe('MAdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RAN
|
|||||||
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }); }));
|
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }); }));
|
||||||
render(<MAdminPluginsPanel />);
|
render(<MAdminPluginsPanel />);
|
||||||
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
||||||
fireEvent.click(await screen.findByRole('button', { name: 'Install anyway' }));
|
fireEvent.click(await screen.findByRole('button', { name: /^Install$/ }));
|
||||||
|
|
||||||
const sheet = await screen.findByRole('dialog', { name: 'Outside its supported TREK versions' });
|
const sheet = await screen.findByRole('dialog', { name: 'Outside its supported TREK versions' });
|
||||||
fireEvent.click(within(sheet).getByRole('button', { name: 'Cancel' }));
|
fireEvent.click(within(sheet).getByRole('button', { name: 'Cancel' }));
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Build server/assets/airports.json from OurAirports (davidmegginson.github.io/ourairports-data).
|
// Build server/assets/airports.json from OurAirports (davidmegginson.github.io/ourairports-data).
|
||||||
// License: Public Domain. Keeps large/medium airports with an IATA code; timezone derived from coords via tz-lookup.
|
// License: Public Domain. Keeps large/medium airports with an IATA code, small ones only with scheduled service;
|
||||||
|
// timezone derived from coords via tz-lookup.
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import https from 'node:https';
|
import https from 'node:https';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -91,7 +92,9 @@ for (let i = 1; i < rows.length; i++) {
|
|||||||
if (!KEEP.has(r[TYPE])) continue;
|
if (!KEEP.has(r[TYPE])) continue;
|
||||||
const iata = r[IATA]?.trim().toUpperCase();
|
const iata = r[IATA]?.trim().toUpperCase();
|
||||||
if (!iata || iata.length !== 3) continue;
|
if (!iata || iata.length !== 3) continue;
|
||||||
if (r[SERVICE] !== 'yes') continue;
|
// The scheduled_service flag lags reality: Little Cayman (LYB) has daily flights and is
|
||||||
|
// marked "no". So it only filters the small fields, where it keeps every airstrip with a code out.
|
||||||
|
if (r[TYPE] === 'small_airport' && r[SERVICE] !== 'yes') continue;
|
||||||
const lat = Number(r[LAT]);
|
const lat = Number(r[LAT]);
|
||||||
const lng = Number(r[LNG]);
|
const lng = Number(r[LNG]);
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"@typescript-eslint/no-require-imports": 18,
|
"@typescript-eslint/no-require-imports": 18,
|
||||||
"@typescript-eslint/no-unsafe-function-type": 1,
|
"@typescript-eslint/no-unsafe-function-type": 1,
|
||||||
"@typescript-eslint/no-unused-vars": 36,
|
"@typescript-eslint/no-unused-vars": 36,
|
||||||
"no-await-in-loop": 563,
|
"no-await-in-loop": 562,
|
||||||
"no-console": 235,
|
"no-console": 235,
|
||||||
"no-empty": 6
|
"no-empty": 6
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"src/nest/budget/budget.mcp.ts": 36,
|
"src/nest/budget/budget.mcp.ts": 36,
|
||||||
"src/nest/collab/collab.mcp.ts": 20,
|
"src/nest/collab/collab.mcp.ts": 20,
|
||||||
"src/nest/collections/collections.mcp.ts": 14,
|
"src/nest/collections/collections.mcp.ts": 14,
|
||||||
"src/nest/day-notes/day-notes.mcp.ts": 10,
|
"src/nest/day-notes/day-notes.mcp.ts": 6,
|
||||||
"src/nest/days/days.mcp.ts": 2,
|
"src/nest/days/days.mcp.ts": 2,
|
||||||
"src/nest/doc-sync/doc-sync.mcp.ts": 1,
|
"src/nest/doc-sync/doc-sync.mcp.ts": 1,
|
||||||
"src/nest/files/files.mcp.ts": 4,
|
"src/nest/files/files.mcp.ts": 4,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"scripts/db-call-graph.mjs": 1036,
|
"scripts/db-call-graph.mjs": 1036,
|
||||||
"scripts/generate-entities.ts": 2259,
|
"scripts/generate-entities.ts": 2259,
|
||||||
"src/db/repositories/Places.repository.ts": 1439,
|
"src/db/repositories/Places.repository.ts": 1438,
|
||||||
"src/db/repositories/Reservations.repository.ts": 1604,
|
"src/db/repositories/Reservations.repository.ts": 1604,
|
||||||
"src/db/repositories/Trips.repository.ts": 1121,
|
"src/db/repositories/Trips.repository.ts": 1121,
|
||||||
"src/db/repositories/Users.repository.ts": 1445,
|
"src/db/repositories/Users.repository.ts": 1445,
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
|
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
|
||||||
"src/nest/journey/journey-domain.service.ts": 1659,
|
"src/nest/journey/journey-domain.service.ts": 1659,
|
||||||
"src/nest/places/places.service.ts": 1592,
|
"src/nest/places/places.service.ts": 1592,
|
||||||
"src/nest/plugins/plugin-runtime.service.ts": 1463,
|
"src/nest/plugins/plugin-runtime.service.ts": 1460,
|
||||||
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
|
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
|
||||||
"src/nest/reservations/reservations.service.ts": 1237,
|
"src/nest/reservations/reservations.service.ts": 1237,
|
||||||
"src/nest/trips/trips.service.ts": 1006,
|
"src/nest/trips/trips.service.ts": 1006,
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export class Plugins {
|
|||||||
update_block_version?: string | null;
|
update_block_version?: string | null;
|
||||||
trek_range?: string | null;
|
trek_range?: string | null;
|
||||||
update_hold: number & Opt = 0;
|
update_hold: number & Opt = 0;
|
||||||
|
trek_range_accepted_version?: string | null;
|
||||||
|
trek_range_accepted_host?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PluginsSchema = defineEntity({
|
export const PluginsSchema = defineEntity({
|
||||||
@@ -72,5 +74,7 @@ export const PluginsSchema = defineEntity({
|
|||||||
update_block_version: p.text().nullable(),
|
update_block_version: p.text().nullable(),
|
||||||
trek_range: p.text().nullable(),
|
trek_range: p.text().nullable(),
|
||||||
update_hold: p.integer().default(0),
|
update_hold: p.integer().default(0),
|
||||||
|
trek_range_accepted_version: p.text().nullable(),
|
||||||
|
trek_range_accepted_host: p.text().nullable(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,4 +34,6 @@ export interface PluginsTable {
|
|||||||
update_block_version: InsertOptional<string | null>;
|
update_block_version: InsertOptional<string | null>;
|
||||||
trek_range: InsertOptional<string | null>;
|
trek_range: InsertOptional<string | null>;
|
||||||
update_hold: InsertOptional<number>;
|
update_hold: InsertOptional<number>;
|
||||||
|
trek_range_accepted_version: InsertOptional<string | null>;
|
||||||
|
trek_range_accepted_host: InsertOptional<string | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
+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.
|
* it was.
|
||||||
*/
|
*/
|
||||||
async listDedupInputs(trip_id: string): Promise<{
|
async listDedupInputs(trip_id: string): Promise<{
|
||||||
name: string | null; lat: number | null; lng: number | null;
|
name: string | null; lat: number | null; lng: number | null; route_geometry: string | null;
|
||||||
google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null;
|
google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null;
|
||||||
}[]> {
|
}[]> {
|
||||||
|
// route_geometry tells a track from a pin: a track to import matches only tracks (#2810).
|
||||||
return this.qb('p')
|
return this.qb('p')
|
||||||
.select(['p.name', 'p.lat', 'p.lng', 'p.google_place_id', 'p.google_ftid', 'p.osm_id', 'p.amap_poi_id'])
|
.select(['p.name', 'p.lat', 'p.lng', 'p.route_geometry',
|
||||||
|
'p.google_place_id', 'p.google_ftid', 'p.osm_id', 'p.amap_poi_id'])
|
||||||
.where('p.trip_id = ?', [trip_id])
|
.where('p.trip_id = ?', [trip_id])
|
||||||
.execute<{
|
.execute<Awaited<ReturnType<PlacesRepository['listDedupInputs']>>>('all', false);
|
||||||
name: string | null; lat: number | null; lng: number | null;
|
|
||||||
google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null;
|
|
||||||
}[]>('all', false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,13 +26,16 @@ export interface PluginIdPermissionRow {
|
|||||||
permissions: string | null;
|
permissions: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PR17 — `SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?` (`assertActivatable`'s one-row activation gate). */
|
/** PR17 — the one-row activation gate `assertActivatable` reads: grants, dependencies, the TREK range and an admin's consent to run outside it. */
|
||||||
export interface PluginActivationGateRow {
|
export interface PluginActivationGateRow {
|
||||||
permissions: string;
|
permissions: string;
|
||||||
granted_permissions: string;
|
granted_permissions: string;
|
||||||
dependencies: string | null;
|
dependencies: string | null;
|
||||||
trek_range: string | null;
|
trek_range: string | null;
|
||||||
api_version: number | null;
|
api_version: number | null;
|
||||||
|
version: string | null;
|
||||||
|
trek_range_accepted_version: string | null;
|
||||||
|
trek_range_accepted_host: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PR18 — `SELECT permissions, config FROM plugins WHERE id = ?` (`spawnActivated`). */
|
/** PR18 — `SELECT permissions, config FROM plugins WHERE id = ?` (`spawnActivated`). */
|
||||||
@@ -89,6 +92,8 @@ export interface PluginAdminRow {
|
|||||||
dependencies: string | null;
|
dependencies: string | null;
|
||||||
operator_egress: number;
|
operator_egress: number;
|
||||||
trek_range: string | null;
|
trek_range: string | null;
|
||||||
|
trek_range_accepted_version: string | null;
|
||||||
|
trek_range_accepted_host: string | null;
|
||||||
author_pubkey: string | null;
|
author_pubkey: string | null;
|
||||||
update_block_code: string | null;
|
update_block_code: string | null;
|
||||||
update_block_detail: string | null;
|
update_block_detail: string | null;
|
||||||
@@ -144,7 +149,18 @@ export class PluginsRepository extends TrekRepository<Plugins> {
|
|||||||
async findActivationGate(id: string): Promise<PluginActivationGateRow | null> {
|
async findActivationGate(id: string): Promise<PluginActivationGateRow | null> {
|
||||||
const row = await this.findOne(
|
const row = await this.findOne(
|
||||||
{ id },
|
{ id },
|
||||||
{ fields: ['permissions', 'granted_permissions', 'dependencies', 'trek_range', 'api_version'] },
|
{
|
||||||
|
fields: [
|
||||||
|
'permissions',
|
||||||
|
'granted_permissions',
|
||||||
|
'dependencies',
|
||||||
|
'trek_range',
|
||||||
|
'api_version',
|
||||||
|
'version',
|
||||||
|
'trek_range_accepted_version',
|
||||||
|
'trek_range_accepted_host',
|
||||||
|
],
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return row
|
return row
|
||||||
? {
|
? {
|
||||||
@@ -153,6 +169,9 @@ export class PluginsRepository extends TrekRepository<Plugins> {
|
|||||||
dependencies: row.dependencies ?? null,
|
dependencies: row.dependencies ?? null,
|
||||||
trek_range: row.trek_range ?? null,
|
trek_range: row.trek_range ?? null,
|
||||||
api_version: row.api_version ?? null,
|
api_version: row.api_version ?? null,
|
||||||
|
version: row.version ?? null,
|
||||||
|
trek_range_accepted_version: row.trek_range_accepted_version ?? null,
|
||||||
|
trek_range_accepted_host: row.trek_range_accepted_host ?? null,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
@@ -370,6 +389,8 @@ export class PluginsRepository extends TrekRepository<Plugins> {
|
|||||||
'dependencies',
|
'dependencies',
|
||||||
'operator_egress',
|
'operator_egress',
|
||||||
'trek_range',
|
'trek_range',
|
||||||
|
'trek_range_accepted_version',
|
||||||
|
'trek_range_accepted_host',
|
||||||
'author_pubkey',
|
'author_pubkey',
|
||||||
'update_block_code',
|
'update_block_code',
|
||||||
'update_block_detail',
|
'update_block_detail',
|
||||||
@@ -396,6 +417,8 @@ export class PluginsRepository extends TrekRepository<Plugins> {
|
|||||||
dependencies: r.dependencies ?? null,
|
dependencies: r.dependencies ?? null,
|
||||||
operator_egress: r.operator_egress,
|
operator_egress: r.operator_egress,
|
||||||
trek_range: r.trek_range ?? null,
|
trek_range: r.trek_range ?? null,
|
||||||
|
trek_range_accepted_version: r.trek_range_accepted_version ?? null,
|
||||||
|
trek_range_accepted_host: r.trek_range_accepted_host ?? null,
|
||||||
author_pubkey: r.author_pubkey ?? null,
|
author_pubkey: r.author_pubkey ?? null,
|
||||||
update_block_code: r.update_block_code ?? null,
|
update_block_code: r.update_block_code ?? null,
|
||||||
update_block_detail: r.update_block_detail ?? null,
|
update_block_detail: r.update_block_detail ?? null,
|
||||||
@@ -545,6 +568,11 @@ export class PluginsRepository extends TrekRepository<Plugins> {
|
|||||||
await this.nativeUpdate({ id }, { update_hold: hold ? 1 : 0 });
|
await this.nativeUpdate({ id }, { update_hold: hold ? 1 : 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An admin accepted running this plugin version outside its TREK range, on this TREK. */
|
||||||
|
async acceptTrekRange(id: string, version: string, host: string): Promise<void> {
|
||||||
|
await this.nativeUpdate({ id }, { trek_range_accepted_version: version, trek_range_accepted_host: host });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RG8 (`commitUpload`, sideload step 2) — `` UPDATE plugins SET source_repo = ?,
|
* RG8 (`commitUpload`, sideload step 2) — `` UPDATE plugins SET source_repo = ?,
|
||||||
* source_commit = NULL, sha256 = NULL, reviewed_at = NULL, author_pubkey = NULL,
|
* source_commit = NULL, sha256 = NULL, reviewed_at = NULL, author_pubkey = NULL,
|
||||||
|
|||||||
@@ -91,7 +91,17 @@ const PROVIDER_MESSAGES: Partial<Record<AssistantErrorCode, string>> = {
|
|||||||
provider_unavailable: 'The provider is not responding',
|
provider_unavailable: 'The provider is not responding',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A provider's failure as the refusal a REST route answers with. */
|
/**
|
||||||
|
* A provider's failure as the refusal a REST route answers with. An own server
|
||||||
|
* that answered without a model list says so in `reason`, so the form can name
|
||||||
|
* the address instead of calling a server that is up unresponsive (#2808).
|
||||||
|
*/
|
||||||
export function providerRefusal(err: AiProviderError): DomainError {
|
export function providerRefusal(err: AiProviderError): DomainError {
|
||||||
|
if (err.providerCode === 'no_model_list') {
|
||||||
|
return new DomainError(REFUSAL_STATUS[err.code], 'The server answered, but sent no model list', {
|
||||||
|
code: err.code,
|
||||||
|
details: { code: err.code, reason: 'no_model_list' },
|
||||||
|
});
|
||||||
|
}
|
||||||
return aiRefusal(err.code, PROVIDER_MESSAGES[err.code] ?? 'The provider refused the request');
|
return aiRefusal(err.code, PROVIDER_MESSAGES[err.code] ?? 'The provider refused the request');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,7 +238,15 @@ export class ModelCatalogService {
|
|||||||
const { models } = await this.llmLocal.listModels(baseUrl ?? undefined, apiKey ?? undefined);
|
const { models } = await this.llmLocal.listModels(baseUrl ?? undefined, apiKey ?? undefined);
|
||||||
return models.map((m) => ({ id: m.name, label: m.name, supportsTools: true }));
|
return models.map((m) => ({ id: m.name, label: m.name, supportsTools: true }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DomainError) throw new AiProviderError('provider_unavailable', err.getStatus(), true);
|
// The server's own answer decides what the admin reads: a refused key, a
|
||||||
|
// server that answered without a model list, or one that is not there.
|
||||||
|
// All three read as "not responding" before, which sent people looking
|
||||||
|
// at a server that was up (#2808).
|
||||||
|
if (err instanceof DomainError) {
|
||||||
|
if (err.code === 'llm_key_refused') throw new AiProviderError('invalid_key', err.getStatus(), false);
|
||||||
|
const reason = err.code === 'llm_no_model_list' ? 'no_model_list' : undefined;
|
||||||
|
throw new AiProviderError('provider_unavailable', err.getStatus(), true, undefined, reason);
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,11 +59,21 @@ export async function refusalOf(kind: AiConnectionKind, res: Response): Promise<
|
|||||||
// refusal the provider's own code already explains (a plan that does not cover it) keeps that code.
|
// refusal the provider's own code already explains (a plan that does not cover it) keeps that code.
|
||||||
const image = namesImageInput(res.status, body) && providerFailure(kind, res.status, named).code === 'generic';
|
const image = namesImageInput(res.status, body) && providerFailure(kind, res.status, named).code === 'generic';
|
||||||
const code = image ? IMAGE_INPUT_REFUSED : named;
|
const code = image ? IMAGE_INPUT_REFUSED : named;
|
||||||
// Status and code only: an error body can quote the request, and the request holds the trip.
|
// Status, code and the refused field's path only: an error message can quote the request, and the
|
||||||
logWarn(`[ai] ${kind} refused the request: HTTP ${res.status}${code ? ` (${code})` : ''}`);
|
// request holds the trip. The path alone says which part of a replayed conversation the provider refused.
|
||||||
|
const param = refusedParamOf(body);
|
||||||
|
logWarn(
|
||||||
|
`[ai] ${kind} refused the request: HTTP ${res.status}${code ? ` (${code})` : ''}${param ? ` at ${param}` : ''}`,
|
||||||
|
);
|
||||||
return providerFailure(kind, res.status, code);
|
return providerFailure(kind, res.status, code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The field an error body names as refused (OpenAI's `param`, such as `input[12]`), when it is a bare path. */
|
||||||
|
function refusedParamOf(body: unknown): string | undefined {
|
||||||
|
const param = asRecord(asRecord(body).error).param;
|
||||||
|
return typeof param === 'string' && /^[\w.[\]-]{1,80}$/.test(param) ? param : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** One SSE data payload as a JSON object; null for anything else (a `[DONE]`, a keepalive). */
|
/** One SSE data payload as a JSON object; null for anything else (a `[DONE]`, a keepalive). */
|
||||||
export function parseEventData(data: string): Record<string, unknown> | null {
|
export function parseEventData(data: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { fitToSize } from './assistant-output';
|
|||||||
import {
|
import {
|
||||||
ADD_FOUND_PLACE_TOOL,
|
ADD_FOUND_PLACE_TOOL,
|
||||||
APPROVAL_TTL_MS,
|
APPROVAL_TTL_MS,
|
||||||
|
ASSISTANT_MODEL_CALLED_SERVER_TOOLS,
|
||||||
ASSISTANT_SERVER_TOOLS,
|
ASSISTANT_SERVER_TOOLS,
|
||||||
IMPORT_BOOKING_TOOL,
|
IMPORT_BOOKING_TOOL,
|
||||||
IMPORT_TOUR_FILE_TOOL,
|
IMPORT_TOUR_FILE_TOOL,
|
||||||
@@ -190,13 +191,6 @@ function earlierCut(text: string, earlier: boolean): string {
|
|||||||
: text;
|
: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The model's own calls on attached files: told after the step that made
|
|
||||||
* them, so the history ends on the user's side when the user's decision on
|
|
||||||
* them goes on.
|
|
||||||
*/
|
|
||||||
const ATTACHED_FILE_TOOLS: ReadonlySet<string> = new Set([READ_ATTACHED_FILE_TOOL, KEEP_ATTACHED_FILE_TOOL]);
|
|
||||||
|
|
||||||
/** What became of keeping an attached file in Files, as its stored part says. */
|
/** What became of keeping an attached file in Files, as its stored part says. */
|
||||||
function keptFileLine(part: ToolCallPart): string {
|
function keptFileLine(part: ToolCallPart): string {
|
||||||
const file = quoted(part.args.name);
|
const file = quoted(part.args.name);
|
||||||
@@ -234,30 +228,31 @@ function attachedReadLine(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the model is told of its reads of attached files in one step: the
|
* The answer a replayed call gets: what became of an attached file (the text
|
||||||
* text of each file the user let it read, the first time the history holds
|
* of a file the user let it read, the first time the history holds it, `told`
|
||||||
* it (`told` keeps track), and what became of a read the user did not allow.
|
* keeping track; `texts` are the shared files' texts by attachment id), "not
|
||||||
* `texts` are the shared files' texts, by attachment id; one no longer there
|
* applied" for a held change that never ran, the stored result otherwise.
|
||||||
* was swept with its file.
|
|
||||||
*/
|
*/
|
||||||
function attachedReadNote(
|
function replayedOutput(
|
||||||
calls: readonly ToolCallPart[],
|
part: ToolCallPart,
|
||||||
texts: ReadonlyMap<string, string>,
|
texts: ReadonlyMap<string, string>,
|
||||||
told: Set<string>,
|
told: Set<string>,
|
||||||
earlier: boolean,
|
earlier: boolean,
|
||||||
): string {
|
): string {
|
||||||
return calls
|
if (part.toolName === KEEP_ATTACHED_FILE_TOOL) return keptFileLine(part);
|
||||||
.map((part) =>
|
if (part.toolName === READ_ATTACHED_FILE_TOOL) return attachedReadLine(part, texts, told, earlier);
|
||||||
part.toolName === KEEP_ATTACHED_FILE_TOOL ? keptFileLine(part) : attachedReadLine(part, texts, told, earlier),
|
if (part.result === undefined) return NOT_APPLIED;
|
||||||
)
|
return outputText(earlier ? fitToSize(part.result, EARLIER_TOOL_OUTPUT_CHARS) : part.result);
|
||||||
.join('\n\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The thread as the provider reads it, oldest first, from its first user
|
* The thread as the provider reads it, oldest first, from its first user
|
||||||
* message on. A step's tool calls are followed by their answers; a held
|
* message on. A step's tool calls are followed by their answers; a held
|
||||||
* change that was never applied answers that it was not, since every call
|
* change that was never applied answers that it was not, since every call
|
||||||
* must be answered. `providerIds` maps a held call's own id to the id the
|
* must be answered. That includes the server's tools the model called itself
|
||||||
|
* (a trip note, an attached file): the providers that replay their own record
|
||||||
|
* of a step refuse it with a call left unanswered, and every later message of
|
||||||
|
* the thread with it. `providerIds` maps a held call's own id to the id the
|
||||||
* provider gave it.
|
* provider gave it.
|
||||||
*/
|
*/
|
||||||
export function toChatHistory(
|
export function toChatHistory(
|
||||||
@@ -282,42 +277,31 @@ export function toChatHistory(
|
|||||||
(part): part is Extract<AssistantMessagePart, { type: 'tool-call' }> =>
|
(part): part is Extract<AssistantMessagePart, { type: 'tool-call' }> =>
|
||||||
part.type === 'tool-call' && part.toolName !== ASSISTANT_WEB_SEARCH_STEP,
|
part.type === 'tool-call' && part.toolName !== ASSISTANT_WEB_SEARCH_STEP,
|
||||||
);
|
);
|
||||||
const reads = allCalls.filter((part) => ATTACHED_FILE_TOOLS.has(part.toolName));
|
const serverStep = (part: ToolCallPart) =>
|
||||||
const serverCalls = allCalls.filter(
|
ASSISTANT_SERVER_TOOLS.has(part.toolName) && !ASSISTANT_MODEL_CALLED_SERVER_TOOLS.has(part.toolName);
|
||||||
(part) => ASSISTANT_SERVER_TOOLS.has(part.toolName) && !ATTACHED_FILE_TOOLS.has(part.toolName),
|
|
||||||
);
|
|
||||||
// Told as the user's side of the conversation: the history then ends on it
|
// Told as the user's side of the conversation: the history then ends on it
|
||||||
// when a decision on these bookings starts the next answer.
|
// when a decision on these bookings starts the next answer.
|
||||||
if (serverCalls.length > 0) history.push({ role: 'user', text: serverStepsNote(serverCalls) });
|
const note = serverStepsNote(allCalls.filter(serverStep));
|
||||||
const calls = allCalls.filter((part) => !ASSISTANT_SERVER_TOOLS.has(part.toolName));
|
if (note) history.push({ role: 'user', text: note });
|
||||||
|
const calls = allCalls.filter((part) => !serverStep(part));
|
||||||
const toolCalls: AiToolCall[] = calls.map((part) => ({
|
const toolCalls: AiToolCall[] = calls.map((part) => ({
|
||||||
id: providerIds.get(part.toolCallId) ?? part.toolCallId,
|
id: providerIds.get(part.toolCallId) ?? part.toolCallId,
|
||||||
name: part.toolName,
|
name: part.toolName,
|
||||||
args: part.args,
|
args: part.args,
|
||||||
}));
|
}));
|
||||||
const text = textOf(parts);
|
const text = textOf(parts);
|
||||||
// Told after the step that read the files, so the history ends on the user's side when a consent goes on.
|
if (!text && toolCalls.length === 0) return;
|
||||||
const readNote = reads.length > 0 ? attachedReadNote(reads, attachedTexts, told, earlier) : '';
|
|
||||||
if (!text && toolCalls.length === 0) {
|
|
||||||
if (readNote) history.push({ role: 'user', text: readNote });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const providerState = decodeJson(ASSISTANT_PROVIDER_STATE, row.provider_state, `message ${row.id}`);
|
const providerState = decodeJson(ASSISTANT_PROVIDER_STATE, row.provider_state, `message ${row.id}`);
|
||||||
history.push({ role: 'assistant', text, toolCalls, providerState });
|
history.push({ role: 'assistant', text, toolCalls, providerState });
|
||||||
calls.forEach((part, index) => {
|
calls.forEach((part, index) => {
|
||||||
const output =
|
|
||||||
part.result === undefined
|
|
||||||
? NOT_APPLIED
|
|
||||||
: outputText(earlier ? fitToSize(part.result, EARLIER_TOOL_OUTPUT_CHARS) : part.result);
|
|
||||||
history.push({
|
history.push({
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
toolCallId: toolCalls[index].id,
|
toolCallId: toolCalls[index].id,
|
||||||
name: part.toolName,
|
name: part.toolName,
|
||||||
output,
|
output: replayedOutput(part, attachedTexts, told, earlier),
|
||||||
...(part.isError ? { isError: true } : {}),
|
...(part.isError ? { isError: true } : {}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (readNote) history.push({ role: 'user', text: readNote });
|
|
||||||
});
|
});
|
||||||
return history;
|
return history;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,6 +255,18 @@ export const ASSISTANT_SERVER_TOOLS: ReadonlySet<string> = new Set([
|
|||||||
...Object.keys(ASSISTANT_SERVER_WRITE_TOOLS),
|
...Object.keys(ASSISTANT_SERVER_WRITE_TOOLS),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's tools the model calls itself. TREK answers them, but they are
|
||||||
|
* calls in the provider's own record of the step, so a replayed step has to
|
||||||
|
* answer them like any other; the rest of the server's tools are steps the
|
||||||
|
* server took, which the provider never saw.
|
||||||
|
*/
|
||||||
|
export const ASSISTANT_MODEL_CALLED_SERVER_TOOLS: ReadonlySet<string> = new Set([
|
||||||
|
REMEMBER_TOOL,
|
||||||
|
READ_ATTACHED_FILE_TOOL,
|
||||||
|
KEEP_ATTACHED_FILE_TOOL,
|
||||||
|
]);
|
||||||
|
|
||||||
/** The largest file the assistant reads. */
|
/** The largest file the assistant reads. */
|
||||||
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
|
|||||||
const dayId = id(args, 'dayId');
|
const dayId = id(args, 'dayId');
|
||||||
const rows = new Rows()
|
const rows = new Rows()
|
||||||
.add('day', dayLabel(ctx, dayId))
|
.add('day', dayLabel(ctx, dayId))
|
||||||
.add('time', text(args.time))
|
.add('content', text(args.time))
|
||||||
.add('icon', text(args.icon))
|
.add('icon', text(args.icon))
|
||||||
.add('color', text(args.color));
|
.add('color', text(args.color));
|
||||||
return { subject: text(args.text), rows, dayId, placeIds: [] };
|
return { subject: text(args.text), rows, dayId, placeIds: [] };
|
||||||
@@ -203,8 +203,8 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
|
|||||||
update_day_note: (ctx, args) => {
|
update_day_note: (ctx, args) => {
|
||||||
const note = ctx.card.notes.get(id(args, 'noteId') ?? 0);
|
const note = ctx.card.notes.get(id(args, 'noteId') ?? 0);
|
||||||
const rows = new Rows();
|
const rows = new Rows();
|
||||||
if (has(args, 'text')) rows.change('text', note?.text ?? null, text(args.text));
|
if (has(args, 'text')) rows.change('title', note?.text ?? null, text(args.text));
|
||||||
if (has(args, 'time')) rows.change('time', note?.time ?? null, text(args.time));
|
if (has(args, 'time')) rows.change('content', note?.time ?? null, text(args.time));
|
||||||
if (has(args, 'icon')) rows.change('icon', note?.icon ?? null, text(args.icon));
|
if (has(args, 'icon')) rows.change('icon', note?.icon ?? null, text(args.icon));
|
||||||
if (has(args, 'color')) rows.change('color', note?.color ?? null, text(args.color));
|
if (has(args, 'color')) rows.change('color', note?.color ?? null, text(args.color));
|
||||||
return { subject: note?.text ?? text(args.text), rows, dayId: id(args, 'dayId'), placeIds: [] };
|
return { subject: note?.text ?? text(args.text), rows, dayId: id(args, 'dayId'), placeIds: [] };
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import {
|
|||||||
} from '../../nest-mcp';
|
} from '../../nest-mcp';
|
||||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||||
import { DayNotesService } from './day-notes.service';
|
import { DayNotesService } from './day-notes.service';
|
||||||
import { idSchema, NOTE_COLORS, type NoteColor } from '@trek/shared';
|
import {
|
||||||
|
dayNoteCreateRequestSchema,
|
||||||
|
dayNoteUpdateRequestSchema,
|
||||||
|
idSchema,
|
||||||
|
NOTE_COLORS,
|
||||||
|
type NoteColor,
|
||||||
|
} from '@trek/shared';
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -23,6 +29,18 @@ import { z } from 'zod';
|
|||||||
*/
|
*/
|
||||||
const noteColorSchema = z.enum(NOTE_COLORS);
|
const noteColorSchema = z.enum(NOTE_COLORS);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note's two text fields carry their names from the time a note was a line
|
||||||
|
* with a clock time: `text` is the heading and `time` the Markdown body under it.
|
||||||
|
* The REST contract holds the bounds, so a note written in the planner can always
|
||||||
|
* be edited here; the descriptions are what tells a model which field is which.
|
||||||
|
*/
|
||||||
|
const NOTE_TITLE = 'The note title, shown as its heading in the day plan. Keep it short';
|
||||||
|
const NOTE_BODY =
|
||||||
|
'The note body shown under the title: details, links, reminders, Markdown allowed. ' +
|
||||||
|
'Despite the field name this is the content, not a clock time';
|
||||||
|
const noteTitleSchema = dayNoteCreateRequestSchema.shape.text.describe(NOTE_TITLE);
|
||||||
|
|
||||||
function parseId(value: string | string[]): number | null {
|
function parseId(value: string | string[]): number | null {
|
||||||
const n = Number(Array.isArray(value) ? value[0] : value);
|
const n = Number(Array.isArray(value) ? value[0] : value);
|
||||||
return Number.isInteger(n) && n > 0 ? n : null;
|
return Number.isInteger(n) && n > 0 ? n : null;
|
||||||
@@ -52,12 +70,12 @@ export class DayNotesMcp {
|
|||||||
|
|
||||||
@Tool({
|
@Tool({
|
||||||
name: 'create_day_note',
|
name: 'create_day_note',
|
||||||
description: 'Add a note to a specific day in a trip.',
|
description: 'Add a note to a specific day in a trip: a title in `text` and its body in `time`.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: idSchema,
|
tripId: idSchema,
|
||||||
dayId: idSchema,
|
dayId: idSchema,
|
||||||
text: z.string().min(1).max(500),
|
text: noteTitleSchema,
|
||||||
time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'),
|
time: dayNoteCreateRequestSchema.shape.time.describe(NOTE_BODY),
|
||||||
icon: z.string().max(64).optional().describe('Emoji icon for the note'),
|
icon: z.string().max(64).optional().describe('Emoji icon for the note'),
|
||||||
color: noteColorSchema
|
color: noteColorSchema
|
||||||
.nullable()
|
.nullable()
|
||||||
@@ -86,7 +104,7 @@ export class DayNotesMcp {
|
|||||||
tripId: number;
|
tripId: number;
|
||||||
dayId: number;
|
dayId: number;
|
||||||
text: string;
|
text: string;
|
||||||
time?: string;
|
time?: string | null;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
color?: NoteColor | null;
|
color?: NoteColor | null;
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
@@ -104,18 +122,14 @@ export class DayNotesMcp {
|
|||||||
|
|
||||||
@Tool({
|
@Tool({
|
||||||
name: 'update_day_note',
|
name: 'update_day_note',
|
||||||
description: 'Edit an existing note on a specific day.',
|
description: 'Edit an existing note on a specific day: its title is `text`, its body `time`.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
tripId: idSchema,
|
tripId: idSchema,
|
||||||
dayId: idSchema,
|
dayId: idSchema,
|
||||||
noteId: idSchema,
|
noteId: idSchema,
|
||||||
text: z.string().min(1).max(500).optional(),
|
// The create bound, so an update cannot blank the title the planner requires.
|
||||||
time: z
|
text: noteTitleSchema.optional(),
|
||||||
.string()
|
time: dayNoteUpdateRequestSchema.shape.time.describe(`${NOTE_BODY}, or null to clear it`),
|
||||||
.max(250)
|
|
||||||
.nullable()
|
|
||||||
.optional()
|
|
||||||
.describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
|
|
||||||
icon: z.string().max(64).optional().describe('Emoji icon for the note'),
|
icon: z.string().max(64).optional().describe('Emoji icon for the note'),
|
||||||
color: noteColorSchema
|
color: noteColorSchema
|
||||||
.nullable()
|
.nullable()
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ const tagsResponseSchema = z.object({
|
|||||||
/** The OpenAI-compatible list (`GET /v1/models`) that LM Studio, vLLM and llama.cpp serve. */
|
/** The OpenAI-compatible list (`GET /v1/models`) that LM Studio, vLLM and llama.cpp serve. */
|
||||||
const openAiModelsSchema = z.object({ data: z.array(z.object({ id: z.string().optional() })) });
|
const openAiModelsSchema = z.object({ data: z.array(z.object({ id: z.string().optional() })) });
|
||||||
|
|
||||||
|
/** One place a server may list its models, and how its answer reads. */
|
||||||
|
interface ModelListSource {
|
||||||
|
url: string;
|
||||||
|
read: (body: unknown) => { name?: string; size?: number }[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** The Bearer header the extraction sends, when a key is configured. */
|
/** The Bearer header the extraction sends, when a key is configured. */
|
||||||
function authHeader(apiKey: string | undefined): Record<string, string> {
|
function authHeader(apiKey: string | undefined): Record<string, string> {
|
||||||
return apiKey ? { authorization: `Bearer ${apiKey}` } : {};
|
return apiKey ? { authorization: `Bearer ${apiKey}` } : {};
|
||||||
@@ -43,11 +49,23 @@ function upstreamRefusal(status: number, apiKey: string | undefined, fallback: s
|
|||||||
apiKey
|
apiKey
|
||||||
? `Local LLM server rejected the API key (${status})`
|
? `Local LLM server rejected the API key (${status})`
|
||||||
: `Local LLM server requires an API key (${status}). Save the key for this address first.`,
|
: `Local LLM server requires an API key (${status}). Save the key for this address first.`,
|
||||||
|
{ code: 'llm_key_refused' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return new DomainError(502, fallback);
|
return new DomainError(502, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL without its trailing slashes. Scanned off instead of `/\/+$/`-replaced:
|
||||||
|
* that pattern re-walks the slash run from every start position of the
|
||||||
|
* admin-supplied URL.
|
||||||
|
*/
|
||||||
|
function trimTrailingSlashes(url: string): string {
|
||||||
|
let end = url.length;
|
||||||
|
while (end > 0 && url.charCodeAt(end - 1) === 47 /* '/' */) end--;
|
||||||
|
return url.slice(0, end);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin helpers for managing a local OpenAI-compatible LLM server (Ollama).
|
* Admin helpers for managing a local OpenAI-compatible LLM server (Ollama).
|
||||||
* Talks to Ollama's *management* API (`/api/tags`, `/api/pull`), which lives at
|
* Talks to Ollama's *management* API (`/api/tags`, `/api/pull`), which lives at
|
||||||
@@ -72,11 +90,7 @@ export class LlmLocalService {
|
|||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
throw new HttpException({ error: 'Base URL must be http(s)' }, 400);
|
throw new HttpException({ error: 'Base URL must be http(s)' }, 400);
|
||||||
}
|
}
|
||||||
// Trailing slashes are scanned off instead of `/\/+$/`-replaced: that pattern
|
return trimTrailingSlashes(raw).replace(/\/v1$/, '');
|
||||||
// re-walks the slash run from every start position of the admin-supplied URL.
|
|
||||||
let end = raw.length;
|
|
||||||
while (end > 0 && raw.charCodeAt(end - 1) === 47 /* '/' */) end--;
|
|
||||||
return raw.slice(0, end).replace(/\/v1$/, '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,39 +108,69 @@ export class LlmLocalService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List models already pulled on the local server. A server without Ollama's
|
* List the models the server offers. Ollama's `/api/tags` is asked first, then
|
||||||
* `/api/tags` (LM Studio, vLLM, llama.cpp) answers 404 there, so its
|
* the OpenAI-compatible list: at the address as entered (the one the chat
|
||||||
* OpenAI-compatible `/v1/models` is asked instead.
|
* calls `/chat/completions` on, `https://api.groq.com/openai/v1` say), then at
|
||||||
|
* `/v1/models` under the root for an address given without `/v1`.
|
||||||
|
*
|
||||||
|
* An answer only counts when it reads as a model list. LM Studio answers an
|
||||||
|
* unknown path with 200 and an error object, and a proxy may answer it with a
|
||||||
|
* login page, so stopping at the first answer that is not a 404 refused
|
||||||
|
* servers whose list was one request further (#2808).
|
||||||
*/
|
*/
|
||||||
async listModels(
|
async listModels(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string | undefined,
|
||||||
apiKey?: string,
|
apiKey?: string,
|
||||||
): Promise<{ models: { name: string; size: number }[] }> {
|
): Promise<{ models: { name: string; size: number }[] }> {
|
||||||
const root = this.ollamaRoot(baseUrl);
|
const root = this.ollamaRoot(baseUrl);
|
||||||
let res = await this.getModelList(`${root}/api/tags`, root, apiKey);
|
const asEntered = trimTrailingSlashes((baseUrl ?? '').trim()) || root;
|
||||||
let openAi = false;
|
const lists: ModelListSource[] = [
|
||||||
if (res.status === 404) {
|
{ url: `${root}/api/tags`, read: (body) => tagsResponseSchema.safeParse(body).data?.models },
|
||||||
const fallback = await this.getModelList(`${root}/v1/models`, root, apiKey);
|
...[...new Set([`${asEntered}/models`, `${root}/v1/models`])].map((url) => ({
|
||||||
if (fallback.ok) {
|
url,
|
||||||
discardBody(res);
|
read: (body: unknown) => openAiModelsSchema.safeParse(body).data?.data.map((m) => ({ name: m.id, size: 0 })),
|
||||||
res = fallback;
|
})),
|
||||||
openAi = true;
|
];
|
||||||
|
const seen = { firstStatus: 0, refusedStatus: 0 };
|
||||||
|
const listed = await this.firstModelList(lists, 0, root, apiKey, seen);
|
||||||
|
if (listed) return { models: listed.map((m) => ({ name: m.name ?? '', size: m.size ?? 0 })).filter((m) => m.name) };
|
||||||
|
if (seen.refusedStatus) throw upstreamRefusal(seen.refusedStatus, apiKey, '');
|
||||||
|
throw new DomainError(502, `Local LLM server sent no model list (${seen.firstStatus})`, {
|
||||||
|
code: 'llm_no_model_list',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first of `lists`, from `index` on, that answers with a readable list,
|
||||||
|
* asked one after the other. Only the first request tells whether the server
|
||||||
|
* is there at all; a later one failing is that path, not the server.
|
||||||
|
*/
|
||||||
|
private async firstModelList(
|
||||||
|
lists: ModelListSource[],
|
||||||
|
index: number,
|
||||||
|
root: string,
|
||||||
|
apiKey: string | undefined,
|
||||||
|
seen: { firstStatus: number; refusedStatus: number },
|
||||||
|
): Promise<{ name?: string; size?: number }[] | null> {
|
||||||
|
const list = lists[index];
|
||||||
|
if (!list) return null;
|
||||||
|
let res: Response | null = null;
|
||||||
|
try {
|
||||||
|
res = await this.getModelList(list.url, root, apiKey);
|
||||||
|
} catch (err) {
|
||||||
|
if (index === 0) throw err;
|
||||||
|
}
|
||||||
|
if (res) {
|
||||||
|
seen.firstStatus ||= res.status;
|
||||||
|
if (res.status === 401 || res.status === 403) seen.refusedStatus ||= res.status;
|
||||||
|
if (res.ok) {
|
||||||
|
const listed = list.read(await readCappedJson<unknown>(res, MAX_LIST_BYTES).catch(() => undefined));
|
||||||
|
if (listed) return listed;
|
||||||
} else {
|
} else {
|
||||||
// Neither list exists: report the first answer, as before.
|
discardBody(res);
|
||||||
discardBody(fallback);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
return this.firstModelList(lists, index + 1, root, apiKey, seen);
|
||||||
discardBody(res);
|
|
||||||
throw upstreamRefusal(res.status, apiKey, `Local LLM server error (${res.status})`);
|
|
||||||
}
|
|
||||||
const body = await readCappedJson<unknown>(res, MAX_LIST_BYTES);
|
|
||||||
const listed = openAi
|
|
||||||
? openAiModelsSchema.safeParse(body).data?.data.map((m) => ({ name: m.id, size: 0 }))
|
|
||||||
: tagsResponseSchema.safeParse(body).data?.models;
|
|
||||||
if (!listed) throw new DomainError(502, 'Local LLM server sent an unreadable model list');
|
|
||||||
const models = listed.map((m) => ({ name: m.name ?? '', size: m.size ?? 0 })).filter((m) => m.name);
|
|
||||||
return { models };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getModelList(url: string, root: string, apiKey: string | undefined): Promise<Response> {
|
private async getModelList(url: string, root: string, apiKey: string | undefined): Promise<Response> {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Minimal Ollama native-API client used by the extraction router.
|
* Minimal Ollama native-API client used by the extraction router. A server that
|
||||||
|
* has no `/api/chat` gets the same extraction over OpenAI-compatible
|
||||||
|
* `/chat/completions` instead (see extractOpenAiCompatible below).
|
||||||
*
|
*
|
||||||
* Why not the OpenAI-compatible `/v1/chat/completions` path the rest of llm-parse uses?
|
* Why not the OpenAI-compatible `/v1/chat/completions` path the rest of llm-parse uses?
|
||||||
* Ollama's `/v1` endpoint does NOT faithfully honour OpenAI's `response_format:{json_schema,strict}`
|
* Ollama's `/v1` endpoint does NOT faithfully honour OpenAI's `response_format:{json_schema,strict}`
|
||||||
@@ -12,7 +14,9 @@
|
|||||||
*/
|
*/
|
||||||
import { readEnv } from '../../../app-config';
|
import { readEnv } from '../../../app-config';
|
||||||
import { safeFetchLlm } from '../../../utils/ssrfGuard';
|
import { safeFetchLlm } from '../../../utils/ssrfGuard';
|
||||||
|
import { OpenAiCompatibleClient } from '../clients/openai-compatible.client';
|
||||||
import { parseLenientJson } from '../lenient-json';
|
import { parseLenientJson } from '../lenient-json';
|
||||||
|
import type { LlmExtractionFile } from '../llm-provider.interface';
|
||||||
|
|
||||||
export interface EnforcedExtractInput {
|
export interface EnforcedExtractInput {
|
||||||
/** Ollama base URL — accepts the addon's `…/v1` form; the `/v1` suffix is stripped. */
|
/** Ollama base URL — accepts the addon's `…/v1` form; the `/v1` suffix is stripped. */
|
||||||
@@ -40,6 +44,13 @@ export function toNativeBase(baseUrl: string): string {
|
|||||||
return baseUrl.slice(0, end).replace(/\/v1$/, '');
|
return baseUrl.slice(0, end).replace(/\/v1$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The answers that say the server has no `/api/chat` at all, unless the body is
|
||||||
|
* Ollama's own 404 for a model it has not pulled (OLLAMA_MISSING_MODEL).
|
||||||
|
*/
|
||||||
|
const NOT_OLLAMA_STATUSES = new Set([404, 405, 501]);
|
||||||
|
const OLLAMA_MISSING_MODEL = /\bmodel\b.*\bnot found\b/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run one schema-constrained chat completion against Ollama's native `/api/chat`.
|
* Run one schema-constrained chat completion against Ollama's native `/api/chat`.
|
||||||
* Returns the parsed JSON object (constrained to `schema`), or null if the request
|
* Returns the parsed JSON object (constrained to `schema`), or null if the request
|
||||||
@@ -85,10 +96,61 @@ export async function extractEnforced(input: EnforcedExtractInput): Promise<Reco
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await res.text().catch(() => '');
|
const detail = await res.text().catch(() => '');
|
||||||
|
if (NOT_OLLAMA_STATUSES.has(res.status) && !OLLAMA_MISSING_MODEL.test(detail))
|
||||||
|
return extractOpenAiCompatible(input);
|
||||||
throw new Error(`Ollama /api/chat failed (${res.status}): ${detail.slice(0, 200)}`);
|
throw new Error(`Ollama /api/chat failed (${res.status}): ${detail.slice(0, 200)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await res.json()) as { message?: { content?: string } };
|
const data = (await res.json().catch(() => null)) as { message?: { content?: string } } | null;
|
||||||
|
// LM Studio answers a path it does not serve with 200 and an error object,
|
||||||
|
// so an answer without Ollama's `message` is a server of another kind too.
|
||||||
|
if (!data || typeof data.message !== 'object') return extractOpenAiCompatible(input);
|
||||||
const parsed = parseLenientJson(data.message?.content);
|
const parsed = parseLenientJson(data.message?.content);
|
||||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
|
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same extraction against an OpenAI-compatible server (Groq, vLLM,
|
||||||
|
* LM Studio, a LiteLLM proxy), which the "Own server" connection also names.
|
||||||
|
* It goes to `/chat/completions` at the address as entered, the endpoint the
|
||||||
|
* assistant chats on, through the client that already handles what such
|
||||||
|
* servers refuse (`json_schema`, `max_tokens`, `temperature`). The schema is
|
||||||
|
* asked for as a one-element list, the shape that client reads (#2806).
|
||||||
|
*/
|
||||||
|
async function extractOpenAiCompatible(input: EnforcedExtractInput): Promise<Record<string, unknown> | null> {
|
||||||
|
const [file, ...pageImages] = (input.images ?? []).map(imageFile);
|
||||||
|
const list = await new OpenAiCompatibleClient().extract({
|
||||||
|
// The shape goes into the prompt as well: the client's later rungs send no
|
||||||
|
// schema (json_object, then nothing), and json_object wants the word JSON.
|
||||||
|
prompt:
|
||||||
|
`${input.system} Answer with JSON only, as {"result": [object]} where the object follows this ` +
|
||||||
|
`JSON Schema: ${JSON.stringify(input.schema)}`,
|
||||||
|
jsonSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { result: { type: 'array', items: input.schema } },
|
||||||
|
required: ['result'],
|
||||||
|
},
|
||||||
|
rootKey: 'result',
|
||||||
|
userText: input.user,
|
||||||
|
model: input.model,
|
||||||
|
baseUrl: input.baseUrl,
|
||||||
|
apiKey: input.apiKey,
|
||||||
|
file,
|
||||||
|
pageImages,
|
||||||
|
});
|
||||||
|
const first = list[0];
|
||||||
|
return first && typeof first === 'object' ? first : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first characters of a base64 image, per type. Anything else is sent as JPEG. */
|
||||||
|
const IMAGE_PREFIXES: [string, string][] = [
|
||||||
|
['iVBOR', 'image/png'],
|
||||||
|
['UklGR', 'image/webp'],
|
||||||
|
['R0lGOD', 'image/gif'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A base64 image as a file, its type read from its first bytes. */
|
||||||
|
function imageFile(base64: string): LlmExtractionFile {
|
||||||
|
const mimeType = IMAGE_PREFIXES.find(([prefix]) => base64.startsWith(prefix))?.[1] ?? 'image/jpeg';
|
||||||
|
return { mimeType, data: Buffer.from(base64, 'base64') };
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,11 +86,16 @@ export async function reclaimPhotoCache(
|
|||||||
|
|
||||||
export interface DedupSet {
|
export interface DedupSet {
|
||||||
names: Set<string>;
|
names: Set<string>;
|
||||||
|
/** The names of the places with a line: a track to import matches only these (#2810). Absent where none is read. */
|
||||||
|
trackNames?: Set<string>;
|
||||||
coords: Array<{ lat: number; lng: number }>;
|
coords: Array<{ lat: number; lng: number }>;
|
||||||
/** Provider ids (google_place_id, google_ftid, osm_id, amap_poi_id) of the places already in the trip. */
|
/** Provider ids (google_place_id, google_ftid, osm_id, amap_poi_id) of the places already in the trip. */
|
||||||
externalIds: Set<string>;
|
externalIds: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A candidate for the dedup set; `track` when it is a line (a GPX track or route, a KML path). */
|
||||||
|
export type DedupCandidate = PlaceMatchCandidate & { track?: boolean };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if a candidate place is already represented in the dedup set.
|
* Returns true if a candidate place is already represented in the dedup set.
|
||||||
*
|
*
|
||||||
@@ -104,12 +109,14 @@ export interface DedupSet {
|
|||||||
* JavaScript's is not, and only unnamed rows contribute coordinates to a dedup
|
* JavaScript's is not, and only unnamed rows contribute coordinates to a dedup
|
||||||
* set. `findDuplicatePlace` spells out where the two still answer differently.
|
* set. `findDuplicatePlace` spells out where the two still answer differently.
|
||||||
*/
|
*/
|
||||||
export function isPlaceDuplicate(candidate: PlaceMatchCandidate, dedup: DedupSet): boolean {
|
export function isPlaceDuplicate(candidate: DedupCandidate, dedup: DedupSet): boolean {
|
||||||
for (const strategy of placeMatchStrategies(candidate)) {
|
for (const strategy of placeMatchStrategies(candidate)) {
|
||||||
if (strategy.by === 'externalId') {
|
if (strategy.by === 'externalId') {
|
||||||
if (dedup.externalIds.has(strategy.id)) return true;
|
if (dedup.externalIds.has(strategy.id)) return true;
|
||||||
} else if (strategy.by === 'name') {
|
} else if (strategy.by === 'name') {
|
||||||
if (dedup.names.has(strategy.name)) return true;
|
// A track is the same as a track of that name, never as a sight of that name:
|
||||||
|
// a watch names a hike after the place it went to (#2810).
|
||||||
|
if ((candidate.track ? dedup.trackNames : dedup.names)?.has(strategy.name)) return true;
|
||||||
} else if (
|
} else if (
|
||||||
dedup.coords.some(
|
dedup.coords.some(
|
||||||
(c) =>
|
(c) =>
|
||||||
@@ -130,29 +137,34 @@ export function isPlaceDuplicate(candidate: PlaceMatchCandidate, dedup: DedupSet
|
|||||||
*/
|
*/
|
||||||
export function dedupSetOf(
|
export function dedupSetOf(
|
||||||
rows: ReadonlyArray<
|
rows: ReadonlyArray<
|
||||||
{ name: string | null; lat: number | null; lng: number | null } & Parameters<typeof externalIdsOf>[0]
|
{ name: string | null; lat: number | null; lng: number | null; route_geometry?: string | null } & Parameters<
|
||||||
|
typeof externalIdsOf
|
||||||
|
>[0]
|
||||||
>,
|
>,
|
||||||
): DedupSet {
|
): DedupSet {
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
|
const trackNames = new Set<string>();
|
||||||
const coords: Array<{ lat: number; lng: number }> = [];
|
const coords: Array<{ lat: number; lng: number }> = [];
|
||||||
const externalIds = new Set<string>();
|
const externalIds = new Set<string>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
for (const id of externalIdsOf(row)) externalIds.add(id);
|
for (const id of externalIdsOf(row)) externalIds.add(id);
|
||||||
if (row.name) {
|
if (row.name) {
|
||||||
names.add(row.name.trim().toLowerCase());
|
names.add(row.name.trim().toLowerCase());
|
||||||
|
if (row.route_geometry) trackNames.add(row.name.trim().toLowerCase());
|
||||||
} else if (row.lat != null && row.lng != null) {
|
} else if (row.lat != null && row.lng != null) {
|
||||||
coords.push({ lat: row.lat, lng: row.lng });
|
coords.push({ lat: row.lat, lng: row.lng });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { names, coords, externalIds };
|
return { names, trackNames, coords, externalIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */
|
/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */
|
||||||
export function trackInsertedInDedupSet(place: PlaceMatchCandidate, dedup: DedupSet): void {
|
export function trackInsertedInDedupSet(place: DedupCandidate, dedup: DedupSet): void {
|
||||||
for (const id of externalIdsOf(place)) dedup.externalIds.add(id);
|
for (const id of externalIdsOf(place)) dedup.externalIds.add(id);
|
||||||
const normalizedName = normalizePlaceName(place.name);
|
const normalizedName = normalizePlaceName(place.name);
|
||||||
if (normalizedName) {
|
if (normalizedName) {
|
||||||
dedup.names.add(normalizedName);
|
dedup.names.add(normalizedName);
|
||||||
|
if (place.track) (dedup.trackNames ??= new Set()).add(normalizedName);
|
||||||
} else if (place.lat != null && place.lng != null) {
|
} else if (place.lat != null && place.lng != null) {
|
||||||
dedup.coords.push({ lat: place.lat, lng: place.lng });
|
dedup.coords.push({ lat: place.lat, lng: place.lng });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -758,7 +758,7 @@ export class PlacesService {
|
|||||||
async gpxDuplicates(tripId: string, rows: PreparedGpxPlace[]): Promise<boolean[]> {
|
async gpxDuplicates(tripId: string, rows: PreparedGpxPlace[]): Promise<boolean[]> {
|
||||||
const dedup = await this.buildDedupSet(tripId);
|
const dedup = await this.buildDedupSet(tripId);
|
||||||
return rows.map((wp) => {
|
return rows.map((wp) => {
|
||||||
const candidate = { name: wp.name, lat: wp.lat, lng: wp.lng };
|
const candidate = { name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry };
|
||||||
if (isPlaceDuplicate(candidate, dedup)) return true;
|
if (isPlaceDuplicate(candidate, dedup)) return true;
|
||||||
trackInsertedInDedupSet(candidate, dedup);
|
trackInsertedInDedupSet(candidate, dedup);
|
||||||
return false;
|
return false;
|
||||||
@@ -925,7 +925,7 @@ export class PlacesService {
|
|||||||
// `findWithTagsAndRatings` INSIDE the caller's transaction, so the read
|
// `findWithTagsAndRatings` INSIDE the caller's transaction, so the read
|
||||||
// sees the still-uncommitted row.
|
// sees the still-uncommitted row.
|
||||||
for (const wp of waypoints) {
|
for (const wp of waypoints) {
|
||||||
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
|
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry }, dedup)) {
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -960,7 +960,7 @@ export class PlacesService {
|
|||||||
});
|
});
|
||||||
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
|
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
|
||||||
created.push(place);
|
created.push(place);
|
||||||
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
|
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry }, dedup);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { places: created, count: created.length, skipped };
|
return { places: created, count: created.length, skipped };
|
||||||
@@ -1033,7 +1033,7 @@ export class PlacesService {
|
|||||||
const fallbackName = `Placemark ${fallbackIndex}`;
|
const fallbackName = `Placemark ${fallbackIndex}`;
|
||||||
const name = parsedPlacemark.name || fallbackName;
|
const name = parsedPlacemark.name || fallbackName;
|
||||||
|
|
||||||
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup)) {
|
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath }, dedup)) {
|
||||||
summary.skippedCount += 1;
|
summary.skippedCount += 1;
|
||||||
dupCount++;
|
dupCount++;
|
||||||
fallbackIndex += 1;
|
fallbackIndex += 1;
|
||||||
@@ -1079,7 +1079,7 @@ export class PlacesService {
|
|||||||
|
|
||||||
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
|
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
|
||||||
created.push(place);
|
created.push(place);
|
||||||
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup);
|
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath }, dedup);
|
||||||
summary.createdCount += 1;
|
summary.createdCount += 1;
|
||||||
fallbackIndex += 1;
|
fallbackIndex += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,18 +126,98 @@ export interface TrekRangeBypass {
|
|||||||
/** The plugin's declared range, or null when it declared none at all. */
|
/** The plugin's declared range, or null when it declared none at all. */
|
||||||
trekRange: string | null;
|
trekRange: string | null;
|
||||||
hostVersion: string;
|
hostVersion: string;
|
||||||
|
/**
|
||||||
|
* True when an admin accepted this one plugin outside its range, false when
|
||||||
|
* TREK_PLUGINS_IGNORE_TREK_RANGE lets every plugin through.
|
||||||
|
*/
|
||||||
|
acceptedByAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An admin's consent to run one plugin outside its range, as the plugins row keeps it. */
|
||||||
|
interface TrekRangeAcceptance {
|
||||||
|
/** The plugin version that was installed when the admin accepted. */
|
||||||
|
acceptedVersion: string | null;
|
||||||
|
/** The TREK version it ran on then. */
|
||||||
|
acceptedHost: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Non-null exactly when `range` would have been refused and only the bypass lets it
|
* Whether an admin's consent still covers the plugin as it is installed now: the
|
||||||
* through: the marker that makes an install/activate response say "this worked, but
|
* same plugin version, on the same TREK major. An update of the plugin, or a TREK
|
||||||
* only because you told TREK to ignore the version check". Null for a normal install
|
* major past the one the admin saw it run on, asks again.
|
||||||
* (the range fits) and null when the bypass is off (the gate refuses instead).
|
|
||||||
*/
|
*/
|
||||||
export function bypassedRange(range: string | null | undefined): TrekRangeBypass | null {
|
export function acceptanceHolds(
|
||||||
|
pluginVersion: string | null | undefined,
|
||||||
|
a: TrekRangeAcceptance | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!a?.acceptedVersion || !a.acceptedHost || !pluginVersion) return false;
|
||||||
|
if (a.acceptedVersion !== pluginVersion) return false;
|
||||||
|
const accepted = semver.coerce(a.acceptedHost);
|
||||||
|
const host = normalizedHost();
|
||||||
|
if (!accepted || host === null) return false;
|
||||||
|
return semver.major(accepted) === semver.major(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-null exactly when `range` would have been refused and only a bypass lets it
|
||||||
|
* through: the marker that makes an install/activate response say "this worked, but
|
||||||
|
* only because you told TREK to run it anyway". `accepted` is this one plugin's
|
||||||
|
* consent from the admin; the environment switch covers every plugin. Null for a
|
||||||
|
* normal install (the range fits) and null when neither applies (the gate refuses).
|
||||||
|
*/
|
||||||
|
export function bypassedRange(range: string | null | undefined, accepted = false): TrekRangeBypass | null {
|
||||||
if (hostSatisfies(range)) return null;
|
if (hostSatisfies(range)) return null;
|
||||||
if (!trekRangeBypassed()) return null;
|
const everyPlugin = trekRangeBypassed();
|
||||||
return { trekRange: range ?? null, hostVersion: hostVersion() };
|
if (!everyPlugin && !accepted) return null;
|
||||||
|
return { trekRange: range ?? null, hostVersion: hostVersion(), acceptedByAdmin: !everyPlugin };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the activation gate reads off a plugin's row to decide whether it may run on this TREK. */
|
||||||
|
interface TrekRangeRow {
|
||||||
|
trek_range: string | null;
|
||||||
|
version: string | null;
|
||||||
|
trek_range_accepted_version: string | null;
|
||||||
|
trek_range_accepted_host: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why the activation gate refuses a plugin on this TREK, in the shape its error carries. */
|
||||||
|
interface TrekRangeRefusal {
|
||||||
|
message: string;
|
||||||
|
code: 'TREK_VERSION_UNKNOWN' | 'TREK_VERSION_INCOMPATIBLE';
|
||||||
|
detail: { trekRange: string | null; hostVersion: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The TREK-version half of the activation gate: null when the plugin may run, else why
|
||||||
|
* not. An admin's consent (`acceptNow`, or one kept on the row for this version and TREK
|
||||||
|
* major) or TREK_PLUGINS_IGNORE_TREK_RANGE lets an outgrown plugin through, and every
|
||||||
|
* start of one says so in the log; the plugin list carries the same marker for the UI.
|
||||||
|
*/
|
||||||
|
export function trekRangeRefusal(id: string, row: TrekRangeRow, acceptNow = false): TrekRangeRefusal | null {
|
||||||
|
const accepted =
|
||||||
|
acceptNow ||
|
||||||
|
acceptanceHolds(row.version, {
|
||||||
|
acceptedVersion: row.trek_range_accepted_version,
|
||||||
|
acceptedHost: row.trek_range_accepted_host,
|
||||||
|
});
|
||||||
|
const bypass = bypassedRange(row.trek_range, accepted);
|
||||||
|
if (bypass) {
|
||||||
|
warnRangeBypass(id, bypass);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!row.trek_range) {
|
||||||
|
return {
|
||||||
|
message: `plugin ${id} does not declare which TREK versions it supports`,
|
||||||
|
code: 'TREK_VERSION_UNKNOWN',
|
||||||
|
detail: { trekRange: null, hostVersion: hostVersion() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (hostSatisfies(row.trek_range)) return null;
|
||||||
|
return {
|
||||||
|
message: `plugin ${id} requires TREK ${row.trek_range} — this is TREK ${hostVersion()}`,
|
||||||
|
code: 'TREK_VERSION_INCOMPATIBLE',
|
||||||
|
detail: { trekRange: row.trek_range, hostVersion: hostVersion() },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,7 +227,8 @@ export function bypassedRange(range: string | null | undefined): TrekRangeBypass
|
|||||||
*/
|
*/
|
||||||
export function warnRangeBypass(id: string, b: TrekRangeBypass): void {
|
export function warnRangeBypass(id: string, b: TrekRangeBypass): void {
|
||||||
const declared = b.trekRange ? `declares TREK ${b.trekRange}` : 'declares no TREK version range';
|
const declared = b.trekRange ? `declares TREK ${b.trekRange}` : 'declares no TREK version range';
|
||||||
|
const because = b.acceptedByAdmin ? 'an admin chose to run it anyway' : 'TREK_PLUGINS_IGNORE_TREK_RANGE is set';
|
||||||
console.warn(
|
console.warn(
|
||||||
`[plugins] ${id} ${declared} — this is TREK ${b.hostVersion}; continuing because TREK_PLUGINS_IGNORE_TREK_RANGE is set. The author has not verified it on this version: it may not work, and it could corrupt TREK data.`,
|
`[plugins] ${id} ${declared} — this is TREK ${b.hostVersion}; continuing because ${because}. The author has not verified it on this version: it may not work, and it could corrupt TREK data.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ import { scanForNativeBinaries } from './install/native-scan';
|
|||||||
import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link';
|
import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link';
|
||||||
import { pluginCodeDir, pluginDataDir } from './paths';
|
import { pluginCodeDir, pluginDataDir } from './paths';
|
||||||
import { assertHostCompatible, PluginRegistryService, RegistryError } from './registry/registry.service';
|
import { assertHostCompatible, PluginRegistryService, RegistryError } from './registry/registry.service';
|
||||||
import { hostSatisfies, hostVersion, bypassedRange, warnRangeBypass } from './install/host-compat';
|
import { hostSatisfies, hostVersion, trekRangeRefusal } from './install/host-compat';
|
||||||
import type { TrekRangeBypass } from './install/host-compat';
|
import type { TrekRangeBypass } from './install/host-compat';
|
||||||
import { keyFingerprint } from './signature-status';
|
import { keyFingerprint } from './signature-status';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
@@ -639,8 +639,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
|||||||
* freshly-installed plugin (no prior grant) is itself the consent for its
|
* freshly-installed plugin (no prior grant) is itself the consent for its
|
||||||
* declared set; widening an already-consented set requires `consentWiden` (the
|
* declared set; widening an already-consented set requires `consentWiden` (the
|
||||||
* update consent dialog), and otherwise throws PluginConsentRequired.
|
* update consent dialog), and otherwise throws PluginConsentRequired.
|
||||||
|
* `acceptTrekRange` is the admin's "run it anyway" outside the TREK range the author
|
||||||
|
* declared: for this plugin only, never a dependency, kept on its row for this version.
|
||||||
*/
|
*/
|
||||||
async activate(id: string, consentWiden = false): Promise<void> {
|
async activate(id: string, consentWiden = false, acceptTrekRange = false): Promise<void> {
|
||||||
const installed = await this.installedDepRows();
|
const installed = await this.installedDepRows();
|
||||||
// Deps-first order over the installed graph (throws DependencyCycleError on a
|
// Deps-first order over the installed graph (throws DependencyCycleError on a
|
||||||
// cycle). Missing deps aren't in `installed` so they don't appear here — the
|
// cycle). Missing deps aren't in `installed` so they don't appear here — the
|
||||||
@@ -652,6 +654,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
|||||||
// blocked dependency never leaves the chain half-activated. Only the target
|
// blocked dependency never leaves the chain half-activated. Only the target
|
||||||
// may consent-widen; dependencies are auto-enabled at their existing grant.
|
// may consent-widen; dependencies are auto-enabled at their existing grant.
|
||||||
const toCheck = rootInstalled ? order : [id];
|
const toCheck = rootInstalled ? order : [id];
|
||||||
|
if (acceptTrekRange) await this.rememberRangeAcceptance(id);
|
||||||
for (const nodeId of toCheck) await this.assertActivatable(nodeId, installed, nodeId === id ? consentWiden : false);
|
for (const nodeId of toCheck) await this.assertActivatable(nodeId, installed, nodeId === id ? consentWiden : false);
|
||||||
|
|
||||||
// Enable dependencies first (skip ones already enabled), then the target.
|
// Enable dependencies first (skip ones already enabled), then the target.
|
||||||
@@ -661,6 +664,16 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the admin's "run it anyway" for the installed version, when it is in fact outside its range.
|
||||||
|
* @txStandalone the admin's answer to a dialog; it stands whether or not the plugin then starts.
|
||||||
|
*/
|
||||||
|
private async rememberRangeAcceptance(id: string): Promise<void> {
|
||||||
|
const row = await this.plugins.findActivationGate(id);
|
||||||
|
if (!row?.version || hostSatisfies(row.trek_range)) return;
|
||||||
|
await this.plugins.acceptTrekRange(id, row.version, hostVersion());
|
||||||
|
}
|
||||||
|
|
||||||
/** All plugin rows projected to what the dependency helpers reason over. */
|
/** All plugin rows projected to what the dependency helpers reason over. */
|
||||||
private async installedDepRows(): Promise<Map<string, PluginDepRow>> {
|
private async installedDepRows(): Promise<Map<string, PluginDepRow>> {
|
||||||
const rows: PluginDepRow[] = await this.plugins.listDepRows();
|
const rows: PluginDepRow[] = await this.plugins.listDepRows();
|
||||||
@@ -682,24 +695,8 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
|||||||
// it would not make the plugin start. It is also the gate that catches the case
|
// it would not make the plugin start. It is also the gate that catches the case
|
||||||
// install can't — TREK was upgraded PAST the plugin's declared upper bound, so code
|
// install can't — TREK was upgraded PAST the plugin's declared upper bound, so code
|
||||||
// that was legitimately installed no longer supports the host it's sitting on.
|
// that was legitimately installed no longer supports the host it's sitting on.
|
||||||
const bypass = bypassedRange(row.trek_range);
|
const refusal = trekRangeRefusal(id, row);
|
||||||
if (bypass) {
|
if (refusal) throw new PluginDependencyError(refusal.message, refusal.code, refusal.detail);
|
||||||
// TREK_PLUGINS_IGNORE_TREK_RANGE: the operator chose to run it anyway. Say so in the
|
|
||||||
// log every time it starts — the plugin list carries the same marker for the UI.
|
|
||||||
warnRangeBypass(id, bypass);
|
|
||||||
} else if (!row.trek_range) {
|
|
||||||
throw new PluginDependencyError(
|
|
||||||
`plugin ${id} does not declare which TREK versions it supports`,
|
|
||||||
'TREK_VERSION_UNKNOWN',
|
|
||||||
{ trekRange: null, hostVersion: hostVersion() },
|
|
||||||
);
|
|
||||||
} else if (!hostSatisfies(row.trek_range)) {
|
|
||||||
throw new PluginDependencyError(
|
|
||||||
`plugin ${id} requires TREK ${row.trek_range} — this is TREK ${hostVersion()}`,
|
|
||||||
'TREK_VERSION_INCOMPATIBLE',
|
|
||||||
{ trekRange: row.trek_range, hostVersion: hostVersion() },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Same reasoning as the TREK-version gate above: a plugin whose manifest apiVersion
|
// Same reasoning as the TREK-version gate above: a plugin whose manifest apiVersion
|
||||||
// outpaces this TREK's plugin-API surface can never run correctly, so it must be
|
// outpaces this TREK's plugin-API surface can never run correctly, so it must be
|
||||||
// refused before any consent dialog is offered.
|
// refused before any consent dialog is offered.
|
||||||
|
|||||||
@@ -113,8 +113,15 @@ export class PluginsController {
|
|||||||
// compatible version and reporting addons the admin still has to enable.
|
// compatible version and reporting addons the admin still has to enable.
|
||||||
// Dependency resolution pins versions internally but never DELIBERATELY, so
|
// Dependency resolution pins versions internally but never DELIBERATELY, so
|
||||||
// only the plain path below recomputes the update hold.
|
// only the plain path below recomputes the update hold.
|
||||||
if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint);
|
const acceptTrekRange = body.acceptTrekRange === true;
|
||||||
const res = await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
|
if (body.withDependencies) {
|
||||||
|
return await this.registry.installWithDependencies(body.id, body.constraint, acceptTrekRange);
|
||||||
|
}
|
||||||
|
const res = await this.registry.install(body.id, {
|
||||||
|
version: body.version,
|
||||||
|
constraint: body.constraint,
|
||||||
|
acceptTrekRange,
|
||||||
|
});
|
||||||
await this.registry.recomputeUpdateHold(res.id, res.version, !!body.version);
|
await this.registry.recomputeUpdateHold(res.id, res.version, !!body.version);
|
||||||
return res;
|
return res;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -272,7 +279,7 @@ export class PluginsController {
|
|||||||
async activate(@Param('id') id: string, @Body() body: PluginActivateDto) {
|
async activate(@Param('id') id: string, @Body() body: PluginActivateDto) {
|
||||||
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
|
||||||
try {
|
try {
|
||||||
await this.runtime.activate(id, !!body?.consent);
|
await this.runtime.activate(id, !!body?.consent, body?.acceptTrekRange === true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Re-enabling a plugin whose update widened its permissions must NOT grant
|
// Re-enabling a plugin whose update widened its permissions must NOT grant
|
||||||
// them silently — surface a distinct code so the UI opens the consent dialog.
|
// them silently — surface a distinct code so the UI opens the consent dialog.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
import { devLinkEnabled } from './dev-link';
|
import { devLinkEnabled } from './dev-link';
|
||||||
import { readAudit } from './host/plugin-audit';
|
import { readAudit } from './host/plugin-audit';
|
||||||
import { pluginBudgetUsage } from './host/plugin-host-state';
|
import { pluginBudgetUsage } from './host/plugin-host-state';
|
||||||
import { bypassedRange, hostSatisfies, hostVersion, trekRangeBypassed } from './install/host-compat';
|
import { acceptanceHolds, bypassedRange, hostSatisfies, hostVersion, trekRangeBypassed } from './install/host-compat';
|
||||||
import type { TrekRangeBypass } from './install/host-compat';
|
import type { TrekRangeBypass } from './install/host-compat';
|
||||||
import type { PluginDependency } from './install/manifest';
|
import type { PluginDependency } from './install/manifest';
|
||||||
import { pluginsEnabled } from './kill-switch';
|
import { pluginsEnabled } from './kill-switch';
|
||||||
@@ -71,6 +71,8 @@ interface PluginRawRow {
|
|||||||
capabilities: string;
|
capabilities: string;
|
||||||
dependencies: string | null;
|
dependencies: string | null;
|
||||||
trek_range: string | null;
|
trek_range: string | null;
|
||||||
|
trek_range_accepted_version: string | null;
|
||||||
|
trek_range_accepted_host: string | null;
|
||||||
author_pubkey: string | null;
|
author_pubkey: string | null;
|
||||||
update_block_code: string | null;
|
update_block_code: string | null;
|
||||||
update_block_detail: string | null;
|
update_block_detail: string | null;
|
||||||
@@ -113,9 +115,10 @@ export interface PluginListItem {
|
|||||||
/** The running TREK, so the UI can say "needs X, you have Y" without doing semver. */
|
/** The running TREK, so the UI can say "needs X, you have Y" without doing semver. */
|
||||||
hostVersion: string;
|
hostVersion: string;
|
||||||
/**
|
/**
|
||||||
* Non-null when the plugin is outside its declared range (or declared none) and only
|
* Non-null when the plugin is outside its declared range (or declared none) and only an
|
||||||
* TREK_PLUGINS_IGNORE_TREK_RANGE lets it activate. The card shows it as a warning
|
* admin's "run it anyway" for this plugin, or TREK_PLUGINS_IGNORE_TREK_RANGE, lets it
|
||||||
* rather than a blocker: the admin chose this, but must keep seeing what they chose.
|
* activate. The card shows it as a warning rather than a blocker: the admin chose this,
|
||||||
|
* but must keep seeing what they chose.
|
||||||
*/
|
*/
|
||||||
trekRangeBypassed: TrekRangeBypass | null;
|
trekRangeBypassed: TrekRangeBypass | null;
|
||||||
/** The concrete blockers, so the UI can render chips + the resolve dialog. */
|
/** The concrete blockers, so the UI can render chips + the resolve dialog. */
|
||||||
@@ -200,7 +203,13 @@ export class PluginsService {
|
|||||||
const state = resolveDependencyState(deps, installed);
|
const state = resolveDependencyState(deps, installed);
|
||||||
// Mirrors the order of assertActivatable's gate, so the card explains the same
|
// Mirrors the order of assertActivatable's gate, so the card explains the same
|
||||||
// blocker the activate call would hit rather than a second, lesser one.
|
// blocker the activate call would hit rather than a second, lesser one.
|
||||||
const trekBypass = bypassedRange(r.trek_range);
|
const trekBypass = bypassedRange(
|
||||||
|
r.trek_range,
|
||||||
|
acceptanceHolds(r.version, {
|
||||||
|
acceptedVersion: r.trek_range_accepted_version,
|
||||||
|
acceptedHost: r.trek_range_accepted_host,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const dependencyStatus: PluginDependencyStatus =
|
const dependencyStatus: PluginDependencyStatus =
|
||||||
!hostSatisfies(r.trek_range) && !trekBypass
|
!hostSatisfies(r.trek_range) && !trekBypass
|
||||||
? 'hostIncompatible'
|
? 'hostIncompatible'
|
||||||
@@ -213,6 +222,8 @@ export class PluginsService {
|
|||||||
dependencies: _raw,
|
dependencies: _raw,
|
||||||
operator_egress: _oe,
|
operator_egress: _oe,
|
||||||
trek_range,
|
trek_range,
|
||||||
|
trek_range_accepted_version: _acceptedVersion,
|
||||||
|
trek_range_accepted_host: _acceptedHost,
|
||||||
author_pubkey,
|
author_pubkey,
|
||||||
update_block_code,
|
update_block_code,
|
||||||
update_block_detail,
|
update_block_detail,
|
||||||
|
|||||||
@@ -392,11 +392,11 @@ export class PluginRegistryService {
|
|||||||
* version (`minTrekVersion`/`maxTrekVersion`). Throws if the plugin isn't in the
|
* version (`minTrekVersion`/`maxTrekVersion`). Throws if the plugin isn't in the
|
||||||
* registry or nothing qualifies. Backs "download the latest compatible version".
|
* registry or nothing qualifies. Backs "download the latest compatible version".
|
||||||
*/
|
*/
|
||||||
async resolveVersion(id: string, constraint?: string): Promise<RegistryVersion> {
|
async resolveVersion(id: string, constraint?: string, acceptTrekRange = false): Promise<RegistryVersion> {
|
||||||
const reg = await this.fetchRegistry();
|
const reg = await this.fetchRegistry();
|
||||||
const entry = reg.plugins.find((p) => p.id === id);
|
const entry = reg.plugins.find((p) => p.id === id);
|
||||||
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
|
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
|
||||||
const v = this.latestCompatible(entry, constraint);
|
const v = this.latestCompatible(entry, constraint) ?? (acceptTrekRange ? this.newestOf(entry, constraint) : null);
|
||||||
if (!v) {
|
if (!v) {
|
||||||
throw new RegistryError(
|
throw new RegistryError(
|
||||||
constraint
|
constraint
|
||||||
@@ -427,6 +427,15 @@ export class PluginRegistryService {
|
|||||||
return [...candidates].sort((a, b) => semver.rcompare(a.version, b.version))[0];
|
return [...candidates].sort((a, b) => semver.rcompare(a.version, b.version))[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest version that satisfies `constraint`, whatever TREK it declares: what an
|
||||||
|
* admin's "install anyway" takes when no version fits this TREK. A version that does
|
||||||
|
* fit is always preferred over this (see {@link selectVersion}).
|
||||||
|
*/
|
||||||
|
private newestOf(entry: RegistryEntry, constraint?: string): RegistryVersion | null {
|
||||||
|
return this.latestCompatible(entry, constraint, () => true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the version `install()` will fetch, and refuse an incompatible one — on EVERY
|
* Pick the version `install()` will fetch, and refuse an incompatible one — on EVERY
|
||||||
* path, which is the whole point. An explicit `version` and the bare "install latest"
|
* path, which is the whole point. An explicit `version` and the bare "install latest"
|
||||||
@@ -434,11 +443,15 @@ export class PluginRegistryService {
|
|||||||
* through resolveVersion), so the admin UI's own Install button — which sends neither —
|
* through resolveVersion), so the admin UI's own Install button — which sends neither —
|
||||||
* happily installed a plugin that declared it doesn't support this TREK.
|
* happily installed a plugin that declared it doesn't support this TREK.
|
||||||
*/
|
*/
|
||||||
private selectVersion(entry: RegistryEntry, opts?: { version?: string; constraint?: string }): RegistryVersion {
|
private selectVersion(
|
||||||
|
entry: RegistryEntry,
|
||||||
|
opts?: { version?: string; constraint?: string; acceptTrekRange?: boolean },
|
||||||
|
): RegistryVersion {
|
||||||
|
const accept = opts?.acceptTrekRange === true;
|
||||||
if (opts?.version) {
|
if (opts?.version) {
|
||||||
const ver = entry.versions.find((v) => v.version === opts.version);
|
const ver = entry.versions.find((v) => v.version === opts.version);
|
||||||
if (!ver) throw new RegistryError(`version ${opts.version} not found for ${entry.id}`);
|
if (!ver) throw new RegistryError(`version ${opts.version} not found for ${entry.id}`);
|
||||||
if (!installable(ver, normalizedHost())) {
|
if (!accept && !installable(ver, normalizedHost())) {
|
||||||
throw new RegistryError(
|
throw new RegistryError(
|
||||||
`${entry.id} ${ver.version} requires TREK ${trekRequirement(ver)} — this is TREK ${hostVersion()}`,
|
`${entry.id} ${ver.version} requires TREK ${trekRequirement(ver)} — this is TREK ${hostVersion()}`,
|
||||||
'TREK_VERSION_INCOMPATIBLE',
|
'TREK_VERSION_INCOMPATIBLE',
|
||||||
@@ -446,7 +459,8 @@ export class PluginRegistryService {
|
|||||||
}
|
}
|
||||||
return ver;
|
return ver;
|
||||||
}
|
}
|
||||||
const ver = this.latestCompatible(entry, opts?.constraint);
|
const ver =
|
||||||
|
this.latestCompatible(entry, opts?.constraint) ?? (accept ? this.newestOf(entry, opts?.constraint) : null);
|
||||||
if (ver) return ver;
|
if (ver) return ver;
|
||||||
// Nothing compatible. Say WHY against the newest version the admin was actually
|
// Nothing compatible. Say WHY against the newest version the admin was actually
|
||||||
// shown, rather than a bare "not found" — the fix is a TREK upgrade, not a retry.
|
// shown, rather than a bare "not found" — the fix is a TREK upgrade, not a retry.
|
||||||
@@ -476,7 +490,7 @@ export class PluginRegistryService {
|
|||||||
*/
|
*/
|
||||||
async install(
|
async install(
|
||||||
id: string,
|
id: string,
|
||||||
opts?: { version?: string; constraint?: string; retrustKey?: string },
|
opts?: { version?: string; constraint?: string; retrustKey?: string; acceptTrekRange?: boolean },
|
||||||
): Promise<{ id: string; version: string; trekRangeBypassed: TrekRangeBypass | null }> {
|
): Promise<{ id: string; version: string; trekRangeBypassed: TrekRangeBypass | null }> {
|
||||||
const reg = await this.fetchRegistry();
|
const reg = await this.fetchRegistry();
|
||||||
const entry = reg.plugins.find((p) => p.id === id);
|
const entry = reg.plugins.find((p) => p.id === id);
|
||||||
@@ -518,7 +532,7 @@ export class PluginRegistryService {
|
|||||||
// published entries usually carry a lower bound and no upper one at all, so a
|
// published entries usually carry a lower bound and no upper one at all, so a
|
||||||
// plugin that declares "<4.0.0" passes the pre-download filter on TREK 4 and is
|
// plugin that declares "<4.0.0" passes the pre-download filter on TREK 4 and is
|
||||||
// caught only here.
|
// caught only here.
|
||||||
const trekRangeBypassed = assertHostCompatible(manifest.trekRange, id);
|
const trekRangeBypassed = assertHostCompatible(manifest.trekRange, id, opts?.acceptTrekRange === true);
|
||||||
if (scanForNativeBinaries(pluginRoot).length) throw new RegistryError('artifact contains native binaries');
|
if (scanForNativeBinaries(pluginRoot).length) throw new RegistryError('artifact contains native binaries');
|
||||||
|
|
||||||
// 6. atomic move into place
|
// 6. atomic move into place
|
||||||
@@ -543,6 +557,9 @@ export class PluginRegistryService {
|
|||||||
// The plugin is now on new code that passed every check — whatever refusal was
|
// The plugin is now on new code that passed every check — whatever refusal was
|
||||||
// recorded before no longer describes reality.
|
// recorded before no longer describes reality.
|
||||||
await clearUpdateBlock(this.plugins, id);
|
await clearUpdateBlock(this.plugins, id);
|
||||||
|
// The admin's "install anyway" for this plugin, kept for the version that landed
|
||||||
|
// so activating it later does not ask a second time.
|
||||||
|
if (trekRangeBypassed?.acceptedByAdmin) await this.plugins.acceptTrekRange(id, ver.version, hostVersion());
|
||||||
});
|
});
|
||||||
return { id, version: ver.version, trekRangeBypassed };
|
return { id, version: ver.version, trekRangeBypassed };
|
||||||
} finally {
|
} finally {
|
||||||
@@ -560,6 +577,7 @@ export class PluginRegistryService {
|
|||||||
async installWithDependencies(
|
async installWithDependencies(
|
||||||
id: string,
|
id: string,
|
||||||
constraint?: string,
|
constraint?: string,
|
||||||
|
acceptTrekRange = false,
|
||||||
): Promise<{ installed: string[]; requiredAddons: string[]; trekRangeBypassed: TrekRangeBypass | null }> {
|
): Promise<{ installed: string[]; requiredAddons: string[]; trekRangeBypassed: TrekRangeBypass | null }> {
|
||||||
let trekRangeBypassed: TrekRangeBypass | null = null;
|
let trekRangeBypassed: TrekRangeBypass | null = null;
|
||||||
const installedNow = new Set(await this.plugins.listAllIds());
|
const installedNow = new Set(await this.plugins.listAllIds());
|
||||||
@@ -570,12 +588,14 @@ export class PluginRegistryService {
|
|||||||
const visit = async (pid: string, range: string | undefined, stack: string[]): Promise<void> => {
|
const visit = async (pid: string, range: string | undefined, stack: string[]): Promise<void> => {
|
||||||
if (done.has(pid)) return;
|
if (done.has(pid)) return;
|
||||||
if (stack.includes(pid)) throw new RegistryError(`plugin dependency cycle: ${[...stack, pid].join(' -> ')}`);
|
if (stack.includes(pid)) throw new RegistryError(`plugin dependency cycle: ${[...stack, pid].join(' -> ')}`);
|
||||||
const ver = await this.resolveVersion(pid, range); // throws if not in registry / unsatisfiable
|
// The admin's "install anyway" covers the plugin they asked for, never a dependency.
|
||||||
|
const acceptHere = acceptTrekRange && pid === id;
|
||||||
|
const ver = await this.resolveVersion(pid, range, acceptHere); // throws if not in registry / unsatisfiable
|
||||||
if (!installedNow.has(pid)) {
|
if (!installedNow.has(pid)) {
|
||||||
// Install the version we just RESOLVED. Passing the range back (or nothing, for
|
// Install the version we just RESOLVED. Passing the range back (or nothing, for
|
||||||
// the root) made install() re-pick on its own and land on entry.versions[0] —
|
// the root) made install() re-pick on its own and land on entry.versions[0] —
|
||||||
// so the compatible version resolveVersion had chosen was computed and discarded.
|
// so the compatible version resolveVersion had chosen was computed and discarded.
|
||||||
const res = await this.install(pid, { version: ver.version });
|
const res = await this.install(pid, { version: ver.version, acceptTrekRange: acceptHere });
|
||||||
// The admin asked for the root; that is the bypass they are warned about (a
|
// The admin asked for the root; that is the bypass they are warned about (a
|
||||||
// dependency's own bypass still lands in the log).
|
// dependency's own bypass still lands in the log).
|
||||||
if (pid === id) trekRangeBypassed = res.trekRangeBypassed;
|
if (pid === id) trekRangeBypassed = res.trekRangeBypassed;
|
||||||
@@ -817,13 +837,14 @@ function installable(v: RegistryVersion, host: string | null): boolean {
|
|||||||
* every install front door (registry, sideload, dev-link) so they all fail the same way,
|
* every install front door (registry, sideload, dev-link) so they all fail the same way,
|
||||||
* with a code the admin UI can act on rather than prose it would have to string-match.
|
* with a code the admin UI can act on rather than prose it would have to string-match.
|
||||||
*
|
*
|
||||||
* Under TREK_PLUGINS_IGNORE_TREK_RANGE the refusal becomes a log warning, and the
|
* Under TREK_PLUGINS_IGNORE_TREK_RANGE, or when the admin `accepted` this one plugin
|
||||||
* returned marker is what the front door hands back so the admin is told too. Null
|
* outside its range, the refusal becomes a log warning, and the returned marker is what
|
||||||
* means the range fits and nothing was bypassed.
|
* the front door hands back so the admin is told too. Null means the range fits and
|
||||||
|
* nothing was bypassed.
|
||||||
*/
|
*/
|
||||||
export function assertHostCompatible(range: string | null, id: string): TrekRangeBypass | null {
|
export function assertHostCompatible(range: string | null, id: string, accepted = false): TrekRangeBypass | null {
|
||||||
if (hostSatisfies(range)) return null;
|
if (hostSatisfies(range)) return null;
|
||||||
const bypass = bypassedRange(range);
|
const bypass = bypassedRange(range, accepted);
|
||||||
if (bypass) {
|
if (bypass) {
|
||||||
warnRangeBypass(id, bypass);
|
warnRangeBypass(id, bypass);
|
||||||
return bypass;
|
return bypass;
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ export function activityFacts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The times an import stores for a track, as the DATETIME columns hold them; NULL without. */
|
/** The times an import stores for a track, as the DATETIME columns hold them; NULL without. */
|
||||||
function storedTimes(row: PreparedGpxPlace | undefined): { started: DbTimestamp | null; ended: DbTimestamp | null } {
|
export function recordedTimesOf(row: PreparedGpxPlace | undefined): {
|
||||||
|
started: DbTimestamp | null;
|
||||||
|
ended: DbTimestamp | null;
|
||||||
|
} {
|
||||||
const times = row?.trackTimes;
|
const times = row?.trackTimes;
|
||||||
return times
|
return times
|
||||||
? { started: dbNow(new Date(times.startedAt)), ended: dbNow(new Date(times.endedAt)) }
|
? { started: dbNow(new Date(times.startedAt)), ended: dbNow(new Date(times.endedAt)) }
|
||||||
@@ -93,7 +96,7 @@ export function trackTimesFor(
|
|||||||
(row, at) => !taken.has(at) && row.name === place.name && row.lat === place.lat && row.lng === place.lng,
|
(row, at) => !taken.has(at) && row.name === place.name && row.lat === place.lat && row.lng === place.lng,
|
||||||
);
|
);
|
||||||
if (index >= 0) taken.add(index);
|
if (index >= 0) taken.add(index);
|
||||||
byPlace.set(place.id, storedTimes(rows[index]));
|
byPlace.set(place.id, recordedTimesOf(rows[index]));
|
||||||
}
|
}
|
||||||
return byPlace;
|
return byPlace;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { PreparedGpxPlace } from '../place-import/place-import.types';
|
|||||||
import { PlacesService } from '../places/places.service';
|
import { PlacesService } from '../places/places.service';
|
||||||
import { readTourFile } from './tour-file-import.helpers';
|
import { readTourFile } from './tour-file-import.helpers';
|
||||||
import { gpxOfLine, splitGpxTrackPieces } from './tour-file-split.helpers';
|
import { gpxOfLine, splitGpxTrackPieces } from './tour-file-split.helpers';
|
||||||
|
import { tracksToAdopt } from './tour-track-adoption';
|
||||||
import { computeTourMetrics, parseRouteGeometry } from './tours.helpers';
|
import { computeTourMetrics, parseRouteGeometry } from './tours.helpers';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { TOUR_GPX_TEXT_MAX, type TourFileLeftOut } from '@trek/shared';
|
import { TOUR_GPX_TEXT_MAX, type TourFileLeftOut } from '@trek/shared';
|
||||||
@@ -15,7 +16,10 @@ export interface TourFileTrack {
|
|||||||
point_count: number;
|
point_count: number;
|
||||||
distance_km: number;
|
distance_km: number;
|
||||||
has_heights: boolean;
|
has_heights: boolean;
|
||||||
/** A track already in the trip under the same name and start, which the import would skip. */
|
/**
|
||||||
|
* A track already in the trip under the same name and start, which the import would skip. A track
|
||||||
|
* from before Tours under the same name is none: the import takes it over as a tour (#2810).
|
||||||
|
*/
|
||||||
duplicate: boolean;
|
duplicate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +59,9 @@ export class TourFileReadService {
|
|||||||
async read(tripId: number, buffer: Buffer, fileName: string): Promise<TourFileTracks> {
|
async read(tripId: number, buffer: Buffer, fileName: string): Promise<TourFileTracks> {
|
||||||
const { rows, leftOut } = await readTourFile(this.placeImport, buffer, fileName);
|
const { rows, leftOut } = await readTourFile(this.placeImport, buffer, fileName);
|
||||||
if (rows.length === 0) return { tracks: [], whole: false };
|
if (rows.length === 0) return { tracks: [], whole: false };
|
||||||
const duplicates = await this.places.gpxDuplicates(String(tripId), rows);
|
const found = await this.places.gpxDuplicates(String(tripId), rows);
|
||||||
|
const adopt = tracksToAdopt(rows, await this.places.list(String(tripId), {}));
|
||||||
|
const duplicates = found.map((duplicate, index) => duplicate && !adopt.has(index));
|
||||||
const extra = leftOut ? { left_out: leftOut } : {};
|
const extra = leftOut ? { left_out: leftOut } : {};
|
||||||
if (!isGpxName(fileName)) {
|
if (!isGpxName(fileName)) {
|
||||||
const tracks = rows.map((row, index) =>
|
const tracks = rows.map((row, index) =>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { normalizePlaceName } from '@trek/shared';
|
||||||
|
|
||||||
|
/** A place of the trip as the places list reads it: enough to tell a track that is no tour yet. */
|
||||||
|
export interface TripPlaceForAdoption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
route_geometry?: string | null;
|
||||||
|
tour_place_id?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A track of the trip a file row takes over: its place and the line it already holds. */
|
||||||
|
export interface AdoptedTrack {
|
||||||
|
id: number;
|
||||||
|
route_geometry: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows of a tour import that take over a track already in the trip, by
|
||||||
|
* row index: a place with a line and no tour yet, under the same name
|
||||||
|
* (trimmed, any case, the duplicate rule's own reading), one place per row
|
||||||
|
* and oldest first. A GPX file imported before Tours existed (4.x) left its
|
||||||
|
* tracks as plain places, and the name alone made every tour import of the
|
||||||
|
* same file a duplicate, with no tour to show for it (#2810).
|
||||||
|
*/
|
||||||
|
export function tracksToAdopt(
|
||||||
|
rows: ReadonlyArray<{ name: string }>,
|
||||||
|
places: ReadonlyArray<TripPlaceForAdoption>,
|
||||||
|
): Map<number, AdoptedTrack> {
|
||||||
|
const byName = new Map<string, AdoptedTrack[]>();
|
||||||
|
for (const place of [...places].sort((a, b) => a.id - b.id)) {
|
||||||
|
const key = normalizePlaceName(place.name);
|
||||||
|
if (!key || !place.route_geometry || place.tour_place_id != null) continue;
|
||||||
|
byName.set(key, [...(byName.get(key) ?? []), { id: place.id, route_geometry: place.route_geometry }]);
|
||||||
|
}
|
||||||
|
const adopt = new Map<number, AdoptedTrack>();
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const key = normalizePlaceName(row.name);
|
||||||
|
const track = key ? byName.get(key)?.shift() : undefined;
|
||||||
|
if (track) adopt.set(index, track);
|
||||||
|
});
|
||||||
|
return adopt;
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import { toRowId } from '../common/row-id';
|
|||||||
import { UnitOfWork } from '../database/unit-of-work';
|
import { UnitOfWork } from '../database/unit-of-work';
|
||||||
import { PlaceImportService } from '../place-import/place-import.service';
|
import { PlaceImportService } from '../place-import/place-import.service';
|
||||||
import { PlacesService } from '../places/places.service';
|
import { PlacesService } from '../places/places.service';
|
||||||
import { toActivity, trackTimesFor } from './tour-activities.helpers';
|
import { recordedTimesOf, toActivity, trackTimesFor } from './tour-activities.helpers';
|
||||||
import {
|
import {
|
||||||
copiedFacet,
|
copiedFacet,
|
||||||
copyStart,
|
copyStart,
|
||||||
@@ -29,6 +29,7 @@ import { readTourFile } from './tour-file-import.helpers';
|
|||||||
import { provenanceSource, tourItemOf } from './tour-item.helpers';
|
import { provenanceSource, tourItemOf } from './tour-item.helpers';
|
||||||
import { TourPaddleCheckService } from './tour-paddle-check.service';
|
import { TourPaddleCheckService } from './tour-paddle-check.service';
|
||||||
import { advanceTourRevision } from './tour-revision';
|
import { advanceTourRevision } from './tour-revision';
|
||||||
|
import { tracksToAdopt } from './tour-track-adoption';
|
||||||
import { toVisit } from './tour-visits.service';
|
import { toVisit } from './tour-visits.service';
|
||||||
import { checkTourCommand, firstPointOf, importFileName, keyedWaypoints, tourRouteFields } from './tour-write.helpers';
|
import { checkTourCommand, firstPointOf, importFileName, keyedWaypoints, tourRouteFields } from './tour-write.helpers';
|
||||||
import { computeTourMetrics, localHashOf, parseRouteGeometry, type GeometryPoint } from './tours.helpers';
|
import { computeTourMetrics, localHashOf, parseRouteGeometry, type GeometryPoint } from './tours.helpers';
|
||||||
@@ -58,6 +59,30 @@ import {
|
|||||||
wandererDifficultyToSac,
|
wandererDifficultyToSac,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
|
|
||||||
|
/** `fn` over `items` one after the other, in order: the writes of one import share a transaction. */
|
||||||
|
async function inOrder<T, R>(items: readonly T[], fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||||
|
return items.reduce<Promise<R[]>>(async (done, item) => [...(await done), await fn(item)], Promise.resolve([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the type adds to each tour a file import makes: a bike tour and a run
|
||||||
|
* their profile, a paddle tour its boat and water, a ski track its discipline
|
||||||
|
* and technique (SKI-02). A run comes in without a pace and a ski track
|
||||||
|
* without a time: neither is worked out for them, so they stay unknown.
|
||||||
|
*/
|
||||||
|
function importedTypeFields(fields: TourGpxImportFields) {
|
||||||
|
return {
|
||||||
|
bike_profile: fields.bike_profile ?? null,
|
||||||
|
run_profile: fields.run_profile ?? null,
|
||||||
|
run_pace_seconds_per_km: null,
|
||||||
|
ski_discipline: fields.ski_discipline ?? null,
|
||||||
|
ski_technique: fields.ski_technique ?? null,
|
||||||
|
boat_type: fields.boat_type ?? null,
|
||||||
|
water_context: fields.water_context ?? null,
|
||||||
|
paddle_speed_kmh: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImportGpxAsTourResult {
|
export interface ImportGpxAsTourResult {
|
||||||
tours: TourListItem[];
|
tours: TourListItem[];
|
||||||
caution: boolean;
|
caution: boolean;
|
||||||
@@ -783,88 +808,45 @@ export class ToursService {
|
|||||||
const { rows } = await readTourFile(this.placeImport, fileBuffer, fileName);
|
const { rows } = await readTourFile(this.placeImport, fileBuffer, fileName);
|
||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
await this.assertTourTypeEnabled(fields.tour_type);
|
await this.assertTourTypeEnabled(fields.tour_type);
|
||||||
// What the type adds to each imported tour: a bike tour and a run their profile, a paddle tour its boat and water,
|
const typeFields = importedTypeFields(fields);
|
||||||
// a ski track its discipline and technique (SKI-02). A run comes in without a pace and a ski track without a
|
|
||||||
// time: neither is worked out for them, so they stay unknown.
|
|
||||||
const typeFields = {
|
|
||||||
bike_profile: fields.bike_profile ?? null,
|
|
||||||
run_profile: fields.run_profile ?? null,
|
|
||||||
run_pace_seconds_per_km: null,
|
|
||||||
ski_discipline: fields.ski_discipline ?? null,
|
|
||||||
ski_technique: fields.ski_technique ?? null,
|
|
||||||
boat_type: fields.boat_type ?? null,
|
|
||||||
water_context: fields.water_context ?? null,
|
|
||||||
paddle_speed_kmh: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const adopt = await this.adoptableTracks(tripId, rows);
|
||||||
|
const fresh = rows.filter((_, index) => !adopt.has(index));
|
||||||
const tours: TourListItem[] = [];
|
const tours: TourListItem[] = [];
|
||||||
|
const adopted: number[] = [];
|
||||||
const result = await this.uow.transactional(async () => {
|
const result = await this.uow.transactional(async () => {
|
||||||
// A nested transactional, so the places and their facets commit together.
|
// A nested transactional, so the places and their facets commit together.
|
||||||
const imported = await this.places.importPreparedGpx(tripId, rows);
|
const imported =
|
||||||
const times = trackTimesFor(rows, imported.places);
|
fresh.length > 0 ? await this.places.importPreparedGpx(tripId, fresh) : { places: [], count: 0, skipped: 0 };
|
||||||
for (const place of imported.places) {
|
const times = trackTimesFor(fresh, imported.places);
|
||||||
const recorded = times.get(place.id);
|
tours.push(
|
||||||
const metrics = computeTourMetrics(parseRouteGeometry(place.route_geometry));
|
...(await inOrder(imported.places, (place) =>
|
||||||
const matchConfidence = metrics.hasElevation ? 1 : 0.3;
|
this.insertImportedTour(
|
||||||
await this.toursRepo.insertTour({
|
place,
|
||||||
place_id: place.id,
|
place.route_geometry ?? null,
|
||||||
tour_type: fields.tour_type,
|
times.get(place.id),
|
||||||
...typeFields,
|
fields,
|
||||||
distance: metrics.distanceKm,
|
typeFields,
|
||||||
elevation_gain: metrics.elevationGainM,
|
fileName,
|
||||||
elevation_loss: metrics.elevationLossM,
|
),
|
||||||
duration: null,
|
)),
|
||||||
planned_duration_minutes: null,
|
);
|
||||||
break_additional_minutes: null,
|
// A track from before Tours keeps its place and its line and becomes the
|
||||||
match_confidence: matchConfidence,
|
// tour; the file adds the recorded times 4.x never stored.
|
||||||
max_hiking_difficulty: null,
|
await inOrder([...adopt], async ([index, track]) => {
|
||||||
origin: 'gpx',
|
const place = await this.places.get(tripId, String(track.id));
|
||||||
origin_file_name: importFileName(fileName),
|
if (!place) return;
|
||||||
track_started_at: recorded?.started ?? null,
|
const recorded = recordedTimesOf(rows[index]);
|
||||||
track_ended_at: recorded?.ended ?? null,
|
tours.push(await this.insertImportedTour(place, track.route_geometry, recorded, fields, typeFields, fileName));
|
||||||
});
|
adopted.push(place.id);
|
||||||
tours.push(
|
});
|
||||||
tourItemOf({
|
|
||||||
place_id: place.id,
|
|
||||||
name: place.name,
|
|
||||||
description: place.description ?? null,
|
|
||||||
website: place.website ?? null,
|
|
||||||
tour_type: fields.tour_type,
|
|
||||||
...typeFields,
|
|
||||||
distance: metrics.distanceKm,
|
|
||||||
elevation_gain: metrics.elevationGainM,
|
|
||||||
elevation_loss: metrics.elevationLossM,
|
|
||||||
duration: null,
|
|
||||||
planned_duration_minutes: null,
|
|
||||||
break_additional_minutes: null,
|
|
||||||
difficulty: null,
|
|
||||||
wanderer_ref: null,
|
|
||||||
match_confidence: matchConfidence,
|
|
||||||
max_hiking_difficulty: null,
|
|
||||||
derived_from_place_id: null,
|
|
||||||
origin: 'gpx',
|
|
||||||
portage_speed_kmh: null,
|
|
||||||
paddle_lake_style: null,
|
|
||||||
track_marks: null,
|
|
||||||
has_recorded_track: 0,
|
|
||||||
planned: 0,
|
|
||||||
has_waypoints: 0,
|
|
||||||
stage_ends: 0,
|
|
||||||
route_missing: 0,
|
|
||||||
revision: 1,
|
|
||||||
hiking_preferences: null,
|
|
||||||
track_started_at: recorded?.started ?? null,
|
|
||||||
track_ended_at: recorded?.ended ?? null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return imported;
|
return imported;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.places.length === 0) return { tours: [], caution: false, skipped: result.skipped };
|
if (tours.length === 0) return { tours: [], caution: false, skipped: result.skipped };
|
||||||
this.broadcastToursChanged(
|
this.broadcastToursChanged(
|
||||||
tripId,
|
tripId,
|
||||||
result.places.map((place) => place.id),
|
tours.map((tour) => tour.place_id),
|
||||||
socketId,
|
socketId,
|
||||||
);
|
);
|
||||||
for (const place of result.places) {
|
for (const place of result.places) {
|
||||||
@@ -874,9 +856,90 @@ export class ToursService {
|
|||||||
this.logger.warn(`Committed GPX place ${place.id}: realtime notification failed`);
|
this.logger.warn(`Committed GPX place ${place.id}: realtime notification failed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The taken-over places now read as tours; the other clients get the row again.
|
||||||
|
await inOrder(adopted, async (id) => {
|
||||||
|
try {
|
||||||
|
const place = await this.places.get(tripId, String(id));
|
||||||
|
if (place) this.places.broadcast(tripId, 'place:updated', { place }, socketId);
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`Committed GPX tour on place ${id}: realtime notification failed`);
|
||||||
|
}
|
||||||
|
});
|
||||||
return { tours, caution: tours.some((t) => t.caution), skipped: result.skipped };
|
return { tours, caution: tours.some((t) => t.caution), skipped: result.skipped };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which rows of a file take over a track of the trip from before Tours (#2810), read off the places list. */
|
||||||
|
private async adoptableTracks(tripId: string, rows: ReadonlyArray<{ name: string }>) {
|
||||||
|
return tracksToAdopt(rows, await this.places.list(tripId, {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `tours` row for one place of a GPX import, with its metrics read off
|
||||||
|
* `geometry` and the recorded times, and the list item the import answers
|
||||||
|
* with. A new place and a track taken over from before Tours (#2810) get the
|
||||||
|
* same row.
|
||||||
|
*/
|
||||||
|
private async insertImportedTour(
|
||||||
|
place: { id: number; name: string; description?: string | null; website?: string | null },
|
||||||
|
geometry: string | null,
|
||||||
|
recorded: ReturnType<typeof recordedTimesOf> | undefined,
|
||||||
|
fields: TourGpxImportFields,
|
||||||
|
typeFields: ReturnType<typeof importedTypeFields>,
|
||||||
|
fileName: string | undefined,
|
||||||
|
): Promise<TourListItem> {
|
||||||
|
const metrics = computeTourMetrics(parseRouteGeometry(geometry));
|
||||||
|
const matchConfidence = metrics.hasElevation ? 1 : 0.3;
|
||||||
|
await this.toursRepo.insertTour({
|
||||||
|
place_id: place.id,
|
||||||
|
tour_type: fields.tour_type,
|
||||||
|
...typeFields,
|
||||||
|
distance: metrics.distanceKm,
|
||||||
|
elevation_gain: metrics.elevationGainM,
|
||||||
|
elevation_loss: metrics.elevationLossM,
|
||||||
|
duration: null,
|
||||||
|
planned_duration_minutes: null,
|
||||||
|
break_additional_minutes: null,
|
||||||
|
match_confidence: matchConfidence,
|
||||||
|
max_hiking_difficulty: null,
|
||||||
|
origin: 'gpx',
|
||||||
|
origin_file_name: importFileName(fileName),
|
||||||
|
track_started_at: recorded?.started ?? null,
|
||||||
|
track_ended_at: recorded?.ended ?? null,
|
||||||
|
});
|
||||||
|
return tourItemOf({
|
||||||
|
place_id: place.id,
|
||||||
|
name: place.name,
|
||||||
|
description: place.description ?? null,
|
||||||
|
website: place.website ?? null,
|
||||||
|
tour_type: fields.tour_type,
|
||||||
|
...typeFields,
|
||||||
|
distance: metrics.distanceKm,
|
||||||
|
elevation_gain: metrics.elevationGainM,
|
||||||
|
elevation_loss: metrics.elevationLossM,
|
||||||
|
duration: null,
|
||||||
|
planned_duration_minutes: null,
|
||||||
|
break_additional_minutes: null,
|
||||||
|
difficulty: null,
|
||||||
|
wanderer_ref: null,
|
||||||
|
match_confidence: matchConfidence,
|
||||||
|
max_hiking_difficulty: null,
|
||||||
|
derived_from_place_id: null,
|
||||||
|
origin: 'gpx',
|
||||||
|
portage_speed_kmh: null,
|
||||||
|
paddle_lake_style: null,
|
||||||
|
track_marks: null,
|
||||||
|
has_recorded_track: 0,
|
||||||
|
planned: 0,
|
||||||
|
has_waypoints: 0,
|
||||||
|
stage_ends: 0,
|
||||||
|
route_missing: 0,
|
||||||
|
revision: 1,
|
||||||
|
hiking_preferences: null,
|
||||||
|
track_started_at: recorded?.started ?? null,
|
||||||
|
track_ended_at: recorded?.ended ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PAD-02: what a file would import, read with the import's own parser and
|
* PAD-02: what a file would import, read with the import's own parser and
|
||||||
* duplicate rule and written nowhere. Null when the file holds no track or
|
* duplicate rule and written nowhere. Null when the file holds no track or
|
||||||
@@ -887,6 +950,7 @@ export class ToursService {
|
|||||||
const { rows, leftOut } = await readTourFile(this.placeImport, fileBuffer, fileName);
|
const { rows, leftOut } = await readTourFile(this.placeImport, fileBuffer, fileName);
|
||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
const duplicates = await this.places.gpxDuplicates(tripId, rows);
|
const duplicates = await this.places.gpxDuplicates(tripId, rows);
|
||||||
|
const adopt = await this.adoptableTracks(tripId, rows);
|
||||||
return {
|
return {
|
||||||
tracks: rows.map((row, index) => {
|
tracks: rows.map((row, index) => {
|
||||||
const geometry = parseRouteGeometry(row.routeGeometry);
|
const geometry = parseRouteGeometry(row.routeGeometry);
|
||||||
@@ -900,7 +964,8 @@ export class ToursService {
|
|||||||
elevation_loss_m: metrics.elevationLossM,
|
elevation_loss_m: metrics.elevationLossM,
|
||||||
started_at: row.trackTimes?.startedAt ?? null,
|
started_at: row.trackTimes?.startedAt ?? null,
|
||||||
ended_at: row.trackTimes?.endedAt ?? null,
|
ended_at: row.trackTimes?.endedAt ?? null,
|
||||||
duplicate: duplicates[index] ?? false,
|
duplicate: !adopt.has(index) && (duplicates[index] ?? false),
|
||||||
|
...(adopt.has(index) ? { adopts_track: true } : {}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
...(leftOut ? { left_out: leftOut } : {}),
|
...(leftOut ? { left_out: leftOut } : {}),
|
||||||
|
|||||||
@@ -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);
|
expect(await countRows(orm, Tours)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('takes over a track from before Tours with the same name instead of skipping it (#2810)', async () => {
|
||||||
|
// 4.x stored a GPX track as a plain place: a line, no tours row.
|
||||||
|
const line = '[[49,12,100],[49.01,12.01,150],[49.02,12.02,120]]';
|
||||||
|
const legacy = (await makePlace(orm, 5, { name: 'Alpine Loop ', lat: 49, lng: 12, route_geometry: line })).id;
|
||||||
|
const elsewhere = (await makePlace(orm, 6, { name: 'Alpine loop', lat: 49, lng: 12, route_geometry: line })).id;
|
||||||
|
|
||||||
|
const preview = await previewGpx(OWNER, TRACK_GPX);
|
||||||
|
expect(preview.status).toBe(200);
|
||||||
|
expect(preview.body.tracks[0]).toMatchObject({ duplicate: false, adopts_track: true });
|
||||||
|
expect(await countRows(orm, Tours)).toBe(0);
|
||||||
|
|
||||||
|
const res = await importGpx(OWNER, TRACK_GPX);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body).toMatchObject({ caution: false, skipped: 0 });
|
||||||
|
expect(res.body.tours.map((tour: { place_id: number }) => tour.place_id)).toEqual([legacy]);
|
||||||
|
expect(await countRows(orm, Places, { trip: 5 })).toBe(1);
|
||||||
|
expect(await placeColumns(legacy, 'name', 'route_geometry')).toEqual({
|
||||||
|
name: 'Alpine Loop ',
|
||||||
|
route_geometry: line,
|
||||||
|
});
|
||||||
|
expect(await findRow(orm, Tours, { place: legacy })).toMatchObject({ tour_type: 'hike', origin: 'gpx' });
|
||||||
|
expect(await findRow(orm, Tours, { place: elsewhere })).toBeNull();
|
||||||
|
expect(broadcast.mock.calls.map((c) => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:updated']));
|
||||||
|
expect(broadcast.mock.calls.map((c) => c[1])).not.toContain('place:created');
|
||||||
|
|
||||||
|
// Now a tour, the same file is a duplicate again.
|
||||||
|
expect((await previewGpx(OWNER, TRACK_GPX)).body.tracks[0]).toMatchObject({ duplicate: true });
|
||||||
|
expect((await importGpx(OWNER, TRACK_GPX)).body).toEqual({ tours: [], caution: false, skipped: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports a hike named after a sight of the trip instead of skipping it as a duplicate (#2810)', async () => {
|
||||||
|
// A watch names the hike after where it went, and that place is already planned.
|
||||||
|
const sight = (await makePlace(orm, 5, { name: 'alpine loop', lat: 47, lng: 11 })).id;
|
||||||
|
|
||||||
|
const preview = await previewGpx(OWNER, TRACK_GPX);
|
||||||
|
expect(preview.body.tracks[0]).toMatchObject({ name: 'Alpine loop', duplicate: false });
|
||||||
|
|
||||||
|
const res = await importGpx(OWNER, TRACK_GPX);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body).toMatchObject({ skipped: 0 });
|
||||||
|
expect(res.body.tours).toHaveLength(1);
|
||||||
|
expect(res.body.tours[0].place_id).not.toBe(sight);
|
||||||
|
expect(await placeColumns(sight, 'route_geometry')).toEqual({ route_geometry: null });
|
||||||
|
});
|
||||||
|
|
||||||
it('400 without a file, and for a file with no track or route', async () => {
|
it('400 without a file, and for a file with no track or route', async () => {
|
||||||
const missing = await importGpx(OWNER, null);
|
const missing = await importGpx(OWNER, null);
|
||||||
expect(missing.status).toBe(400);
|
expect(missing.status).toBe(400);
|
||||||
|
|||||||
@@ -117,11 +117,11 @@ describe('PluginRuntimeService dev-link', () => {
|
|||||||
try {
|
try {
|
||||||
await expect(runtime.link(writeSource('oldplug-bypass', { trek: '>=2.0.0 <3.0.0' }))).resolves.toMatchObject({
|
await expect(runtime.link(writeSource('oldplug-bypass', { trek: '>=2.0.0 <3.0.0' }))).resolves.toMatchObject({
|
||||||
id: 'oldplug-bypass',
|
id: 'oldplug-bypass',
|
||||||
trekRangeBypassed: { trekRange: '>=2.0.0 <3.0.0', hostVersion: '3.3.0' },
|
trekRangeBypassed: { trekRange: '>=2.0.0 <3.0.0', hostVersion: '3.3.0', acceptedByAdmin: false },
|
||||||
});
|
});
|
||||||
await expect(runtime.link(writeSource('rangeless-bypass', { trek: '' }))).resolves.toMatchObject({
|
await expect(runtime.link(writeSource('rangeless-bypass', { trek: '' }))).resolves.toMatchObject({
|
||||||
id: 'rangeless-bypass',
|
id: 'rangeless-bypass',
|
||||||
trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0' },
|
trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0', acceptedByAdmin: false },
|
||||||
});
|
});
|
||||||
// A dir that fits is a plain link — no marker to alarm anyone with.
|
// A dir that fits is a plain link — no marker to alarm anyone with.
|
||||||
await expect(runtime.link(writeSource('fits-bypass'))).resolves.toMatchObject({ trekRangeBypassed: null });
|
await expect(runtime.link(writeSource('fits-bypass'))).resolves.toMatchObject({ trekRangeBypassed: null });
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ const { testDb } = vi.hoisted(() => {
|
|||||||
-- otherwise fail on the version gate before reaching the behaviour under test. The gate
|
-- otherwise fail on the version gate before reaching the behaviour under test. The gate
|
||||||
-- itself is exercised in "TREK host-version gating" below, with explicit ranges.
|
-- itself is exercised in "TREK host-version gating" below, with explicit ranges.
|
||||||
trek_range TEXT DEFAULT '>=3.0.0',
|
trek_range TEXT DEFAULT '>=3.0.0',
|
||||||
source_repo TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
|
source_repo TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT,
|
||||||
|
trek_range_accepted_version TEXT, trek_range_accepted_host TEXT);
|
||||||
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
|
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
|
||||||
CREATE TABLE plugin_settings_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT);
|
CREATE TABLE plugin_settings_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT);
|
||||||
CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT);
|
CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT);
|
||||||
@@ -818,14 +819,27 @@ describe('PluginRuntimeService.retrust', () => {
|
|||||||
* host it declared it doesn't support — would otherwise be spawned on the next boot.
|
* host it declared it doesn't support — would otherwise be spawned on the next boot.
|
||||||
*/
|
*/
|
||||||
describe('TREK host-version gating', () => {
|
describe('TREK host-version gating', () => {
|
||||||
const seedRanged = (id: string, trekRange: string | null, enabled = 0) => {
|
const seedRanged = (
|
||||||
|
id: string,
|
||||||
|
trekRange: string | null,
|
||||||
|
enabled = 0,
|
||||||
|
accepted: { version?: string; acceptedVersion?: string; acceptedHost?: string } = {},
|
||||||
|
) => {
|
||||||
fs.mkdirSync(path.join(codeRoot, id, 'server'), { recursive: true });
|
fs.mkdirSync(path.join(codeRoot, id, 'server'), { recursive: true });
|
||||||
fs.writeFileSync(path.join(codeRoot, id, 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
|
fs.writeFileSync(path.join(codeRoot, id, 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
|
||||||
testDb
|
testDb
|
||||||
.prepare(
|
.prepare(
|
||||||
"INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range) VALUES (?, ?, ?, '[]', '[]', '{}', ?)",
|
"INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range, version, trek_range_accepted_version, trek_range_accepted_host) VALUES (?, ?, ?, '[]', '[]', '{}', ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.run(id, enabled ? 'active' : 'inactive', enabled, trekRange);
|
.run(
|
||||||
|
id,
|
||||||
|
enabled ? 'active' : 'inactive',
|
||||||
|
enabled,
|
||||||
|
trekRange,
|
||||||
|
accepted.version ?? null,
|
||||||
|
accepted.acceptedVersion ?? null,
|
||||||
|
accepted.acceptedHost ?? null,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
const cleanup = (...ids: string[]) => {
|
const cleanup = (...ids: string[]) => {
|
||||||
for (const id of ids) testDb.prepare('DELETE FROM plugins WHERE id = ?').run(id);
|
for (const id of ids) testDb.prepare('DELETE FROM plugins WHERE id = ?').run(id);
|
||||||
@@ -919,6 +933,43 @@ describe('TREK host-version gating', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs an outgrown plugin on the admin's say-so, and does not ask a second time", async () => {
|
||||||
|
process.env.APP_VERSION = '5.0.0';
|
||||||
|
seedRanged('say-so', '>=3.2.0 <4.0.0', 0, { version: '1.0.3' });
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const rt = await createPluginRuntime(dbConn);
|
||||||
|
await rt.activate('say-so', false, true);
|
||||||
|
expect(rt.isActive('say-so')).toBe(true);
|
||||||
|
expect(warn.mock.calls.some((c) => /say-so.*an admin chose to run it anyway/.test(String(c[0])))).toBe(true);
|
||||||
|
await rt.deactivate('say-so');
|
||||||
|
|
||||||
|
// The consent was kept with the row, so switching it on again needs no second one.
|
||||||
|
await rt.activate('say-so');
|
||||||
|
expect(rt.isActive('say-so')).toBe(true);
|
||||||
|
await rt.deactivate('say-so');
|
||||||
|
warn.mockRestore();
|
||||||
|
cleanup('say-so');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the admin's consent for the version and TREK major it was given on only", async () => {
|
||||||
|
const given = { acceptedVersion: '1.0.3', acceptedHost: '5.0.0' };
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
process.env.APP_VERSION = '5.0.1';
|
||||||
|
seedRanged('kept', '>=3.2.0 <4.0.0', 0, { version: '1.0.3', ...given });
|
||||||
|
seedRanged('updated', '>=3.2.0 <4.0.0', 0, { version: '1.1.0', ...given });
|
||||||
|
const rt = await createPluginRuntime(dbConn);
|
||||||
|
await rt.activate('kept');
|
||||||
|
expect(rt.isActive('kept')).toBe(true);
|
||||||
|
await rt.deactivate('kept');
|
||||||
|
// An update of the plugin asks again.
|
||||||
|
await expect(rt.activate('updated')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||||
|
// So does a new TREK major.
|
||||||
|
process.env.APP_VERSION = '6.0.0';
|
||||||
|
await expect(rt.activate('kept')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||||
|
warn.mockRestore();
|
||||||
|
cleanup('kept', 'updated');
|
||||||
|
});
|
||||||
|
|
||||||
it('checks the version BEFORE permissions — an unrunnable plugin gets no consent dialog', async () => {
|
it('checks the version BEFORE permissions — an unrunnable plugin gets no consent dialog', async () => {
|
||||||
// Consenting to permissions would not make it start, so offering the dialog is a lie.
|
// Consenting to permissions would not make it start, so offering the dialog is a lie.
|
||||||
process.env.APP_VERSION = '4.0.0';
|
process.env.APP_VERSION = '4.0.0';
|
||||||
|
|||||||
@@ -1193,6 +1193,43 @@ describe('TREK-version gating on install', () => {
|
|||||||
expect(safeDownload).not.toHaveBeenCalled();
|
expect(safeDownload).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("an admin's install-anyway takes the newest version when none fits, and keeps that consent on the row", async () => {
|
||||||
|
process.env.APP_VERSION = '4.0.0'; // both versions declare <4.0.0
|
||||||
|
stubMulti();
|
||||||
|
stageMulti('2.1.0', 0);
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
await expect(svc.install('multi', { acceptTrekRange: true })).resolves.toEqual({
|
||||||
|
id: 'multi',
|
||||||
|
version: '2.1.0',
|
||||||
|
trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '4.0.0', acceptedByAdmin: true },
|
||||||
|
});
|
||||||
|
expect(warn.mock.calls.some((c) => /multi.*an admin chose to run it anyway/.test(String(c[0])))).toBe(true);
|
||||||
|
warn.mockRestore();
|
||||||
|
const row = await findRow(t, Plugins, { id: 'multi' });
|
||||||
|
expect(row).toMatchObject({ trek_range_accepted_version: '2.1.0', trek_range_accepted_host: '4.0.0' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an admin's install-anyway still prefers a version that fits", async () => {
|
||||||
|
process.env.APP_VERSION = '3.3.0';
|
||||||
|
stubMulti();
|
||||||
|
stageMulti('2.0.0', 1);
|
||||||
|
await expect(svc.install('multi', { acceptTrekRange: true })).resolves.toEqual({
|
||||||
|
id: 'multi',
|
||||||
|
version: '2.0.0',
|
||||||
|
trekRangeBypassed: null,
|
||||||
|
});
|
||||||
|
expect(await findRow(t, Plugins, { id: 'multi' })).toMatchObject({ trek_range_accepted_version: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an admin's install-anyway covers the plugin they asked for, never a dependency", async () => {
|
||||||
|
process.env.APP_VERSION = '4.0.0';
|
||||||
|
stubMulti();
|
||||||
|
await expect(svc.resolveVersion('multi', undefined, false)).rejects.toMatchObject({
|
||||||
|
code: 'TREK_VERSION_INCOMPATIBLE',
|
||||||
|
});
|
||||||
|
await expect(svc.resolveVersion('multi', undefined, true)).resolves.toMatchObject({ version: '2.1.0' });
|
||||||
|
});
|
||||||
|
|
||||||
it('honours an EXCLUSIVE upper bound, which minTrekVersion alone cannot express', async () => {
|
it('honours an EXCLUSIVE upper bound, which minTrekVersion alone cannot express', async () => {
|
||||||
// Both versions declare "<4.0.0" and neither carries a maxTrekVersion — under the old
|
// Both versions declare "<4.0.0" and neither carries a maxTrekVersion — under the old
|
||||||
// min-only check TREK 4 looked compatible with everything.
|
// min-only check TREK 4 looked compatible with everything.
|
||||||
@@ -1223,7 +1260,7 @@ describe('TREK-version gating on install', () => {
|
|||||||
await expect(svc.install('multi')).resolves.toEqual({
|
await expect(svc.install('multi')).resolves.toEqual({
|
||||||
id: 'multi',
|
id: 'multi',
|
||||||
version: '2.1.0',
|
version: '2.1.0',
|
||||||
trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '3.3.0' },
|
trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false },
|
||||||
});
|
});
|
||||||
expect(warn.mock.calls.some((c) => /multi.*>=3\.4\.0 <4\.0\.0.*3\.3\.0/.test(String(c[0])))).toBe(true);
|
expect(warn.mock.calls.some((c) => /multi.*>=3\.4\.0 <4\.0\.0.*3\.3\.0/.test(String(c[0])))).toBe(true);
|
||||||
warn.mockRestore();
|
warn.mockRestore();
|
||||||
@@ -1235,7 +1272,7 @@ describe('TREK-version gating on install', () => {
|
|||||||
stageMulti('2.1.0', 0);
|
stageMulti('2.1.0', 0);
|
||||||
await expect(svc.install('multi', { version: '2.1.0' })).resolves.toMatchObject({
|
await expect(svc.install('multi', { version: '2.1.0' })).resolves.toMatchObject({
|
||||||
version: '2.1.0',
|
version: '2.1.0',
|
||||||
trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '3.3.0' },
|
trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1268,7 +1305,7 @@ describe('TREK-version gating on install', () => {
|
|||||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
||||||
await expect(svc.install('flight-tracker')).resolves.toMatchObject({
|
await expect(svc.install('flight-tracker')).resolves.toMatchObject({
|
||||||
id: 'flight-tracker',
|
id: 'flight-tracker',
|
||||||
trekRangeBypassed: { trekRange: '>=3.2.0 <3.4.0', hostVersion: '3.9.0' },
|
trekRangeBypassed: { trekRange: '>=3.2.0 <3.4.0', hostVersion: '3.9.0', acceptedByAdmin: false },
|
||||||
});
|
});
|
||||||
expect(fs.existsSync(path.join(codeRoot, 'flight-tracker'))).toBe(true);
|
expect(fs.existsSync(path.join(codeRoot, 'flight-tracker'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1287,7 +1324,7 @@ describe('TREK-version gating on install', () => {
|
|||||||
REGISTRY.plugins[0].versions[0].sha256 = sha;
|
REGISTRY.plugins[0].versions[0].sha256 = sha;
|
||||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
||||||
await expect(svc.install('flight-tracker')).resolves.toMatchObject({
|
await expect(svc.install('flight-tracker')).resolves.toMatchObject({
|
||||||
trekRangeBypassed: { trekRange: null, hostVersion: '3.9.0' },
|
trekRangeBypassed: { trekRange: null, hostVersion: '3.9.0', acceptedByAdmin: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1298,13 +1335,15 @@ describe('TREK-version gating on install', () => {
|
|||||||
);
|
);
|
||||||
expect(old).toMatchObject({
|
expect(old).toMatchObject({
|
||||||
id: 'my-upload',
|
id: 'my-upload',
|
||||||
trekRangeBypassed: { trekRange: '>=3.0.0 <3.5.0', hostVersion: '3.9.0' },
|
trekRangeBypassed: { trekRange: '>=3.0.0 <3.5.0', hostVersion: '3.9.0', acceptedByAdmin: false },
|
||||||
});
|
});
|
||||||
fs.rmSync(old.stagingDir, { recursive: true, force: true });
|
fs.rmSync(old.stagingDir, { recursive: true, force: true });
|
||||||
const rangeless = svc.stageUpload(
|
const rangeless = svc.stageUpload(
|
||||||
makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', trek: undefined }),
|
makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', trek: undefined }),
|
||||||
);
|
);
|
||||||
expect(rangeless).toMatchObject({ trekRangeBypassed: { trekRange: null, hostVersion: '3.9.0' } });
|
expect(rangeless).toMatchObject({
|
||||||
|
trekRangeBypassed: { trekRange: null, hostVersion: '3.9.0', acceptedByAdmin: false },
|
||||||
|
});
|
||||||
fs.rmSync(rangeless.stagingDir, { recursive: true, force: true });
|
fs.rmSync(rangeless.stagingDir, { recursive: true, force: true });
|
||||||
const fits = svc.stageUpload(
|
const fits = svc.stageUpload(
|
||||||
makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration' }),
|
makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration' }),
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ const UNNUMBERED = [
|
|||||||
'Migration20200101046280_an_assistant_conversation_can_be_about_no_one_trip',
|
'Migration20200101046280_an_assistant_conversation_can_be_about_no_one_trip',
|
||||||
'Migration20200101046290_a_removed_member_cannot_rejoin_by_invite_link',
|
'Migration20200101046290_a_removed_member_cannot_rejoin_by_invite_link',
|
||||||
'Migration20200101046300_an_attached_file_stays_with_its_conversation',
|
'Migration20200101046300_an_attached_file_stays_with_its_conversation',
|
||||||
|
'Migration20200101046310_an_admin_can_run_a_plugin_outside_its_trek_range',
|
||||||
];
|
];
|
||||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||||
// schema_version past it.
|
// schema_version past it.
|
||||||
|
|||||||
@@ -574,20 +574,21 @@ describe('PlacesRepository.listForGpx (PL29) and existsByImageUrl (PI1)', () =>
|
|||||||
* by, the service-level dedup tests.
|
* by, the service-level dedup tests.
|
||||||
*/
|
*/
|
||||||
describe('PlacesRepository.listDedupInputs (PL24)', () => {
|
describe('PlacesRepository.listDedupInputs (PL24)', () => {
|
||||||
it('PLACEREPO-048: projects exactly the seven dedup columns, scoped to the trip', async () => {
|
it('PLACEREPO-048: projects exactly the eight dedup columns, the line included (#2810), scoped to the trip', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
const other = createTrip(testDb, user.id);
|
const other = createTrip(testDb, user.id);
|
||||||
await insertRow(t.orm, Places, {
|
await insertRow(t.orm, Places, {
|
||||||
trip: trip.id, name: 'Louvre', lat: 48.86, lng: 2.34, google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1',
|
trip: trip.id, name: 'Louvre', lat: 48.86, lng: 2.34, google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1',
|
||||||
|
route_geometry: '[[48.86,2.34],[48.87,2.35]]',
|
||||||
});
|
});
|
||||||
await insertRow(t.orm, Places, { trip: trip.id, name: '', lat: 1.1, lng: 2.2 });
|
await insertRow(t.orm, Places, { trip: trip.id, name: '', lat: 1.1, lng: 2.2 });
|
||||||
createPlace(testDb, other.id, { name: 'Not this trip' });
|
createPlace(testDb, other.id, { name: 'Not this trip' });
|
||||||
|
|
||||||
const rows = await places.listDedupInputs(String(trip.id));
|
const rows = await places.listDedupInputs(String(trip.id));
|
||||||
expect(rows).toEqual([
|
expect(rows).toEqual([
|
||||||
{ name: 'Louvre', lat: 48.86, lng: 2.34, google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1' },
|
{ name: 'Louvre', lat: 48.86, lng: 2.34, route_geometry: '[[48.86,2.34],[48.87,2.35]]', google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1' },
|
||||||
{ name: '', lat: 1.1, lng: 2.2, google_place_id: null, google_ftid: null, osm_id: null, amap_poi_id: null },
|
{ name: '', lat: 1.1, lng: 2.2, route_geometry: null, google_place_id: null, google_ftid: null, osm_id: null, amap_poi_id: null },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,43 @@ describe('Tool: create_day_note', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('describes text as the title and time as the body, as the planner shows them', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
await withHarness(user.id, async (h) => {
|
||||||
|
const { tools } = await h.client.listTools();
|
||||||
|
for (const name of ['create_day_note', 'update_day_note']) {
|
||||||
|
const props = tools.find((t) => t.name === name)?.inputSchema.properties as Record<string, unknown>;
|
||||||
|
// The nullable body may come out as an anyOf, so read the property whole.
|
||||||
|
const time = JSON.stringify(props.time);
|
||||||
|
expect(JSON.stringify(props.text)).toMatch(/title/i);
|
||||||
|
expect(time).toMatch(/body/i);
|
||||||
|
expect(time).not.toMatch(/time label/i);
|
||||||
|
expect(time).toContain('"maxLength":2000');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a body of 2000 characters, the bound the planner allows, and refuses one more', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id);
|
||||||
|
await withHarness(user.id, async (h) => {
|
||||||
|
const body = 'b'.repeat(2000);
|
||||||
|
const result = await h.client.callTool({
|
||||||
|
name: 'create_day_note',
|
||||||
|
arguments: { tripId: trip.id, dayId: day.id, text: 'Museum day', time: body },
|
||||||
|
});
|
||||||
|
const data = parseToolResult(result) as { note: { id: number } };
|
||||||
|
expect((await dayNote(data.note.id)).time).toBe(body);
|
||||||
|
const tooLong = await h.client.callTool({
|
||||||
|
name: 'create_day_note',
|
||||||
|
arguments: { tripId: trip.id, dayId: day.id, text: 'Museum day', time: `${body}b` },
|
||||||
|
});
|
||||||
|
expect(tooLong.isError).toBe(true);
|
||||||
|
expect(await countRows(orm, DayNotes, { day: day.id })).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('defaults icon to 📝', async () => {
|
it('defaults icon to 📝', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
@@ -220,6 +257,45 @@ describe('Tool: update_day_note', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('edits a note whose body is longer than 250 characters without shortening it', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id);
|
||||||
|
const longBody = 'Opening hours and the ticket links. '.repeat(20);
|
||||||
|
const note = createDayNote(testDb, day.id, trip.id, { text: 'Museum day', time: longBody });
|
||||||
|
await withHarness(user.id, async (h) => {
|
||||||
|
const renamed = await h.client.callTool({
|
||||||
|
name: 'update_day_note',
|
||||||
|
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'Museums' },
|
||||||
|
});
|
||||||
|
expect(renamed.isError).toBeFalsy();
|
||||||
|
const longer = `${longBody}Bring the pass.`;
|
||||||
|
const rewritten = await h.client.callTool({
|
||||||
|
name: 'update_day_note',
|
||||||
|
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, time: longer },
|
||||||
|
});
|
||||||
|
expect(rewritten.isError).toBeFalsy();
|
||||||
|
const row = await dayNote(note.id);
|
||||||
|
expect(row.text).toBe('Museums');
|
||||||
|
expect(row.time).toBe(longer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an empty title on update and keeps the stored one', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id);
|
||||||
|
const note = createDayNote(testDb, day.id, trip.id, { text: 'Keep me' });
|
||||||
|
await withHarness(user.id, async (h) => {
|
||||||
|
const result = await h.client.callTool({
|
||||||
|
name: 'update_day_note',
|
||||||
|
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: '' },
|
||||||
|
});
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
expect((await dayNote(note.id)).text).toBe('Keep me');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('trims text whitespace', async () => {
|
it('trims text whitespace', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const trip = createTrip(testDb, user.id);
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|||||||
@@ -127,6 +127,16 @@ describe('constants', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('AIHLP-031b: an own server that answered without a model list says so in reason (#2808)', () => {
|
||||||
|
const err = providerRefusal(new AiProviderError('provider_unavailable', 404, true, undefined, 'no_model_list'));
|
||||||
|
expect(err.getStatus()).toBe(502);
|
||||||
|
expect(err.toBody()).toEqual({
|
||||||
|
error: 'The server answered, but sent no model list',
|
||||||
|
code: 'provider_unavailable',
|
||||||
|
reason: 'no_model_list',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('AIHLP-032: the host a conversation goes to', () => {
|
it('AIHLP-032: the host a conversation goes to', () => {
|
||||||
expect(aiHostOf('anthropic', null)).toBe('api.anthropic.com');
|
expect(aiHostOf('anthropic', null)).toBe('api.anthropic.com');
|
||||||
expect(aiHostOf('chatgpt', null)).toBe('api.openai.com');
|
expect(aiHostOf('chatgpt', null)).toBe('api.openai.com');
|
||||||
|
|||||||
@@ -201,6 +201,19 @@ describe('ModelCatalogService', () => {
|
|||||||
).rejects.toMatchObject({ code: 'provider_unavailable', status: 502 });
|
).rejects.toMatchObject({ code: 'provider_unavailable', status: 502 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('MCAT-007b: a custom server that refuses the key, or answers without a list, says so (#2808)', async () => {
|
||||||
|
const target = { kind: 'custom' as const, baseUrl: 'https://llm.example.org/v1', secret: 'k' };
|
||||||
|
llmLocal.listModels.mockRejectedValueOnce(new DomainError(502, 'rejected', { code: 'llm_key_refused' }));
|
||||||
|
await expect(catalog().list(target)).rejects.toMatchObject({ code: 'invalid_key', retryable: false });
|
||||||
|
llmLocal.listModels.mockRejectedValueOnce(new DomainError(502, 'no list', { code: 'llm_no_model_list' }));
|
||||||
|
await expect(catalog().list(target)).rejects.toMatchObject({
|
||||||
|
code: 'provider_unavailable',
|
||||||
|
providerCode: 'no_model_list',
|
||||||
|
});
|
||||||
|
llmLocal.listModels.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
await expect(catalog().list(target)).rejects.toThrow('boom');
|
||||||
|
});
|
||||||
|
|
||||||
it('MCAT-008: the default model is a capable one, else the first listed', () => {
|
it('MCAT-008: the default model is a capable one, else the first listed', () => {
|
||||||
const list = (...ids: string[]) => ids.map((id) => ({ id, label: id, supportsTools: true }));
|
const list = (...ids: string[]) => ids.map((id) => ({ id, label: id, supportsTools: true }));
|
||||||
expect(pickDefaultModel('anthropic', list('claude-haiku', 'claude-sonnet-5', 'claude-opus'))).toBe(
|
expect(pickDefaultModel('anthropic', list('claude-haiku', 'claude-sonnet-5', 'claude-opus'))).toBe(
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ import { toProviderSchema, type AiFetch } from '../../../../src/nest/ai-connecti
|
|||||||
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { logWarn } = vi.hoisted(() => ({ logWarn: vi.fn() }));
|
||||||
|
vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('../../../../src/nest/audit/audit-log.logger')>()),
|
||||||
|
logWarn,
|
||||||
|
}));
|
||||||
|
|
||||||
/** An SSE body from whole events, cut every `every` characters so no event arrives in one piece. */
|
/** An SSE body from whole events, cut every `every` characters so no event arrives in one piece. */
|
||||||
function sseBody(events: unknown[], every = 7): ReadableStream<Uint8Array> {
|
function sseBody(events: unknown[], every = 7): ReadableStream<Uint8Array> {
|
||||||
const text = events.map((e) => `data: ${typeof e === 'string' ? e : JSON.stringify(e)}\n\n`).join('');
|
const text = events.map((e) => `data: ${typeof e === 'string' ? e : JSON.stringify(e)}\n\n`).join('');
|
||||||
@@ -145,6 +151,40 @@ describe('provider errors', () => {
|
|||||||
expect(errorCodeOf(null)).toBeUndefined();
|
expect(errorCodeOf(null)).toBeUndefined();
|
||||||
expect(errorCodeOf({})).toBeUndefined();
|
expect(errorCodeOf({})).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('PERR-004: a refusal is logged with the path of the field it names, never with its message (#2845)', async () => {
|
||||||
|
const refused = (param: unknown) =>
|
||||||
|
failure(
|
||||||
|
new OpenAiResponsesClient(
|
||||||
|
'm',
|
||||||
|
async () => 'at',
|
||||||
|
fetchAnswering(
|
||||||
|
jsonResponse(400, {
|
||||||
|
error: {
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
code: null,
|
||||||
|
message: 'No tool output found for function call call_note. Dinner in Rome at 20:00.',
|
||||||
|
param,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).fetcher,
|
||||||
|
).stream(REQUEST, signal()),
|
||||||
|
);
|
||||||
|
|
||||||
|
logWarn.mockClear();
|
||||||
|
expect(await refused('input[3]')).toMatchObject({ code: 'generic', status: 400 });
|
||||||
|
expect(logWarn).toHaveBeenCalledWith(
|
||||||
|
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error) at input[3]',
|
||||||
|
);
|
||||||
|
|
||||||
|
logWarn.mockClear();
|
||||||
|
await refused('input[3] Dinner in Rome');
|
||||||
|
await refused(null);
|
||||||
|
expect(logWarn.mock.calls.map(([line]) => line)).toEqual([
|
||||||
|
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error)',
|
||||||
|
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error)',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toProviderSchema', () => {
|
describe('toProviderSchema', () => {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* The bundled assets/airports.json itself, not a fixture.
|
||||||
|
*
|
||||||
|
* OurAirports marks some airports with daily flights as having no scheduled
|
||||||
|
* service, and the build used to drop them on that flag alone (#2820, Little
|
||||||
|
* Cayman). These pin a few of them and the codes saved bookings already point at.
|
||||||
|
*/
|
||||||
|
import { findByIata } from '../../../../src/nest/airports/airports.data';
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('bundled airport dataset', () => {
|
||||||
|
it('AIRPORT-ASSET-001: keeps airports OurAirports flags as unscheduled', () => {
|
||||||
|
expect(findByIata('LYB')?.name).toBe('Edward Bodden Little Cayman Airfield');
|
||||||
|
expect(findByIata('VEY')?.country).toBe('IS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AIRPORT-ASSET-002: still resolves the codes it carried before', () => {
|
||||||
|
for (const code of ['PBI', 'ADA', 'GCM', 'CYB', 'FRA']) {
|
||||||
|
expect(findByIata(code), code).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
*/
|
*/
|
||||||
import type { AssistantMessageRow } from '../../../../src/db/repositories/AssistantMessages.repository';
|
import type { AssistantMessageRow } from '../../../../src/db/repositories/AssistantMessages.repository';
|
||||||
import type { AssistantToolCallRow } from '../../../../src/db/repositories/AssistantToolCalls.repository';
|
import type { AssistantToolCallRow } from '../../../../src/db/repositories/AssistantToolCalls.repository';
|
||||||
|
import type { AiChatMessage } from '../../../../src/nest/ai-connections/ai-chat-client.interface';
|
||||||
|
import { toAnthropicMessages } from '../../../../src/nest/ai-connections/providers/anthropic-messages.client';
|
||||||
|
import { toResponsesInput } from '../../../../src/nest/ai-connections/providers/openai-responses.client';
|
||||||
import {
|
import {
|
||||||
messageIdOf,
|
messageIdOf,
|
||||||
outputText,
|
outputText,
|
||||||
@@ -249,7 +252,7 @@ describe('toChatHistory', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ASTHIS-005d: the text of a file the user let the model read comes after the step, once, cut in earlier turns', () => {
|
it('ASTHIS-005d: the text of a file the user let the model read answers its read, once, cut in earlier turns', () => {
|
||||||
const id = '0b7c2f0e-6f6d-4a8e-9b1a-3d2c1e0f9a87';
|
const id = '0b7c2f0e-6f6d-4a8e-9b1a-3d2c1e0f9a87';
|
||||||
type CallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>;
|
type CallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>;
|
||||||
const read = (toolCallId: string, extra: Partial<CallPart>): AssistantMessagePart => ({
|
const read = (toolCallId: string, extra: Partial<CallPart>): AssistantMessagePart => ({
|
||||||
@@ -270,20 +273,27 @@ describe('toChatHistory', () => {
|
|||||||
row('user', [{ type: 'text', text: 'Thanks' }]),
|
row('user', [{ type: 'text', text: 'Thanks' }]),
|
||||||
];
|
];
|
||||||
const history = toChatHistory(rows, new Map(), new Map([[id, long]]));
|
const history = toChatHistory(rows, new Map(), new Map([[id, long]]));
|
||||||
expect(history.map((message) => message.role)).toEqual(['user', 'assistant', 'user', 'user', 'user']);
|
expect(history.map((message) => message.role)).toEqual(['user', 'assistant', 'tool', 'assistant', 'tool', 'user']);
|
||||||
expect(history[1]).toMatchObject({ role: 'assistant', text: 'I need to read it first.', toolCalls: [] });
|
expect(history[1]).toMatchObject({
|
||||||
const told = (history[2] as { text: string }).text;
|
role: 'assistant',
|
||||||
|
text: 'I need to read it first.',
|
||||||
|
toolCalls: [{ id: 'r1', name: 'read_attached_file' }],
|
||||||
|
});
|
||||||
|
expect(history[2]).toMatchObject({ role: 'tool', toolCallId: 'r1' });
|
||||||
|
const told = (history[2] as { output: string }).output;
|
||||||
expect(told).toContain('The user let you read the attached file "AirBnB Buchung.pdf".');
|
expect(told).toContain('The user let you read the attached file "AirBnB Buchung.pdf".');
|
||||||
expect(told).toContain('Casa Lisboa');
|
expect(told).toContain('Casa Lisboa');
|
||||||
// An earlier turn's text is cut so it does not crowd out the conversation.
|
// An earlier turn's text is cut so it does not crowd out the conversation.
|
||||||
expect(told.length).toBeLessThan(long.length);
|
expect(told.length).toBeLessThan(long.length);
|
||||||
expect(told.endsWith('[...]')).toBe(true);
|
expect(told.endsWith('[...]')).toBe(true);
|
||||||
expect((history[3] as { text: string }).text).toBe(
|
expect(history[4]).toMatchObject({
|
||||||
'You read the attached file "AirBnB Buchung.pdf" again; its text is above.',
|
role: 'tool',
|
||||||
);
|
toolCallId: 'r2',
|
||||||
|
output: 'You read the attached file "AirBnB Buchung.pdf" again; its text is above.',
|
||||||
|
});
|
||||||
// The latest turn keeps the whole text.
|
// The latest turn keeps the whole text.
|
||||||
const latest = toChatHistory(rows.slice(0, 2), new Map(), new Map([[id, long]]));
|
const latest = toChatHistory(rows.slice(0, 2), new Map(), new Map([[id, long]]));
|
||||||
expect((latest[2] as { text: string }).text).toContain(long);
|
expect((latest[2] as { output: string }).output).toContain(long);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ASTHIS-005e: a read the user did not allow, one still waiting, a refused one and a swept file say so', () => {
|
it('ASTHIS-005e: a read the user did not allow, one still waiting, a refused one and a swept file say so', () => {
|
||||||
@@ -303,21 +313,21 @@ describe('toChatHistory', () => {
|
|||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
new Map(),
|
new Map(),
|
||||||
).map((message) => (message as { text: string }).text);
|
).map((message) => (message.role === 'tool' ? message.output : null));
|
||||||
expect(step({ approval: { id: 'r', approved: false } })[1]).toBe(
|
expect(step({ approval: { id: 'r', approved: false } })[2]).toBe(
|
||||||
'The user did not let you read the attached file "a.pdf".',
|
'The user did not let you read the attached file "a.pdf".',
|
||||||
);
|
);
|
||||||
expect(step({ approval: { id: 'r' } })[1]).toContain("waits for the user's agreement; you have not read it.");
|
expect(step({ approval: { id: 'r' } })[2]).toContain("waits for the user's agreement; you have not read it.");
|
||||||
expect(step({ approval: { id: 'r', resolution: 'expired' } })[1]).toContain('you have not read it');
|
expect(step({ approval: { id: 'r', resolution: 'expired' } })[2]).toContain('you have not read it');
|
||||||
expect(step({ result: 'No such attachment in this conversation.', isError: true })[1]).toBe(
|
expect(step({ result: 'No such attachment in this conversation.', isError: true })[2]).toBe(
|
||||||
'Reading the attached file "a.pdf" failed: No such attachment in this conversation.',
|
'Reading the attached file "a.pdf" failed: No such attachment in this conversation.',
|
||||||
);
|
);
|
||||||
expect(step({ result: { read: true } })[1]).toBe(
|
expect(step({ result: { read: true } })[2]).toBe(
|
||||||
'The attached file "a.pdf" is no longer kept; you cannot read it again.',
|
'The attached file "a.pdf" is no longer kept; you cannot read it again.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ASTHIS-005f: keeping an attached file is told after its step, whatever became of it', () => {
|
it('ASTHIS-005f: keeping an attached file is answered with what became of it', () => {
|
||||||
const keep = (extra: Partial<Extract<AssistantMessagePart, { type: 'tool-call' }>>) =>
|
const keep = (extra: Partial<Extract<AssistantMessagePart, { type: 'tool-call' }>>) =>
|
||||||
toChatHistory(
|
toChatHistory(
|
||||||
[
|
[
|
||||||
@@ -336,11 +346,12 @@ describe('toChatHistory', () => {
|
|||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
const kept = keep({ approval: { id: 'k', approved: true }, result: { file: { id: 31 } } });
|
const kept = keep({ approval: { id: 'k', approved: true }, result: { file: { id: 31 } } });
|
||||||
expect(kept.map((message) => message.role)).toEqual(['user', 'assistant', 'user']);
|
expect(kept.map((message) => message.role)).toEqual(['user', 'assistant', 'tool']);
|
||||||
expect((kept[2] as { text: string }).text).toBe(
|
expect(kept[2]).toMatchObject({
|
||||||
'The attached file "a.pdf" is in the trip\'s Files now (file id 31).',
|
toolCallId: 'k',
|
||||||
);
|
output: 'The attached file "a.pdf" is in the trip\'s Files now (file id 31).',
|
||||||
const lastOf = (extra: Parameters<typeof keep>[0]) => (keep(extra).at(-1) as { text: string }).text;
|
});
|
||||||
|
const lastOf = (extra: Parameters<typeof keep>[0]) => (keep(extra).at(-1) as { output: string }).output;
|
||||||
expect(
|
expect(
|
||||||
lastOf({ approval: { id: 'k', approved: true }, result: 'The file could not be kept.', isError: true }),
|
lastOf({ approval: { id: 'k', approved: true }, result: 'The file could not be kept.', isError: true }),
|
||||||
).toBe('The attached file "a.pdf" could not be kept in Files: The file could not be kept.');
|
).toBe('The attached file "a.pdf" could not be kept in Files: The file could not be kept.');
|
||||||
@@ -448,6 +459,151 @@ describe('toChatHistory', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('toChatHistory: calls the model made of the server tools (#2845)', () => {
|
||||||
|
/**
|
||||||
|
* The thread of #2845: a step that kept a trip note on the way (it runs at
|
||||||
|
* once), then a step whose changes the user applied, both with the
|
||||||
|
* provider's own record of the step stored beside them.
|
||||||
|
*/
|
||||||
|
function appliedAfterANote(
|
||||||
|
noteState: Record<string, unknown>,
|
||||||
|
changeState: Record<string, unknown>,
|
||||||
|
): AiChatMessage[] {
|
||||||
|
const rows = [
|
||||||
|
row('user', [{ type: 'text', text: 'Dinner at 20:00 on day 2, and remember we like late dinners' }]),
|
||||||
|
row(
|
||||||
|
'assistant',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: 'call_note',
|
||||||
|
toolName: 'remember_for_trip',
|
||||||
|
args: { note: 'Likes late dinners' },
|
||||||
|
result: { notes: 'Likes late dinners' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ provider_state: JSON.stringify(noteState) },
|
||||||
|
),
|
||||||
|
row(
|
||||||
|
'assistant',
|
||||||
|
[
|
||||||
|
{ type: 'text', text: 'Shall I move it?' },
|
||||||
|
{
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: 'held-1',
|
||||||
|
toolName: 'update_assignment_time',
|
||||||
|
args: { assignmentId: 7, place_time: '20:00' },
|
||||||
|
result: { assignment: { id: 7 } },
|
||||||
|
approval: { id: 'held-1', approved: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ provider_state: JSON.stringify(changeState) },
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return toChatHistory(rows, new Map([['held-1', 'call_move']]));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('ASTHIS-015: the Responses API gets an answer for every call it made, a kept note included', () => {
|
||||||
|
const history = appliedAfterANote(
|
||||||
|
{
|
||||||
|
adapter: 'openai-responses',
|
||||||
|
items: [
|
||||||
|
{ type: 'reasoning', id: 'rs_1', encrypted_content: 'x' },
|
||||||
|
{ type: 'function_call', call_id: 'call_note', name: 'remember_for_trip', arguments: '{}' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adapter: 'openai-responses',
|
||||||
|
items: [
|
||||||
|
{ type: 'reasoning', id: 'rs_2', encrypted_content: 'y' },
|
||||||
|
{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Shall I move it?' }] },
|
||||||
|
{ type: 'function_call', call_id: 'call_move', name: 'update_assignment_time', arguments: '{}' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const input = toResponsesInput({ system: '', messages: history, tools: [] });
|
||||||
|
const made = input.filter((item) => item.type === 'function_call').map((item) => item.call_id);
|
||||||
|
const answered = input.filter((item) => item.type === 'function_call_output').map((item) => item.call_id);
|
||||||
|
expect(made).toEqual(['call_note', 'call_move']);
|
||||||
|
expect(answered).toEqual(['call_note', 'call_move']);
|
||||||
|
for (const id of made) {
|
||||||
|
const call = input.findIndex((item) => item.type === 'function_call' && item.call_id === id);
|
||||||
|
const output = input.findIndex((item) => item.type === 'function_call_output' && item.call_id === id);
|
||||||
|
expect(output).toBeGreaterThan(call);
|
||||||
|
}
|
||||||
|
expect(input.find((item) => item.type === 'function_call_output' && item.call_id === 'call_note')).toMatchObject({
|
||||||
|
output: '{"notes":"Likes late dinners"}',
|
||||||
|
});
|
||||||
|
// Nothing empty goes out as the user's: a kept note is no step of the server to tell.
|
||||||
|
const userTexts = input
|
||||||
|
.filter((item) => item.type === 'message' && item.role === 'user')
|
||||||
|
.flatMap((item) => (item.content as Array<{ text?: string }>).map((part) => part.text ?? ''));
|
||||||
|
expect(userTexts).toEqual(['Dinner at 20:00 on day 2, and remember we like late dinners']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASTHIS-016: Anthropic gets a tool_result for every tool_use it replays, a kept note included', () => {
|
||||||
|
const history = appliedAfterANote(
|
||||||
|
{
|
||||||
|
adapter: 'anthropic-messages',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'thinking', thinking: 'note it', signature: 's1' },
|
||||||
|
{ type: 'tool_use', id: 'call_note', name: 'remember_for_trip', input: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
adapter: 'anthropic-messages',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'thinking', thinking: 'move it', signature: 's2' },
|
||||||
|
{ type: 'text', text: 'Shall I move it?' },
|
||||||
|
{ type: 'tool_use', id: 'call_move', name: 'update_assignment_time', input: {} },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const messages = toAnthropicMessages(history);
|
||||||
|
const uses = messages.flatMap((message) => message.content).filter((block) => block.type === 'tool_use');
|
||||||
|
expect(uses.map((block) => block.id)).toEqual(['call_note', 'call_move']);
|
||||||
|
messages.forEach((message, index) => {
|
||||||
|
const ids = message.content.filter((block) => block.type === 'tool_use').map((block) => block.id);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const next = messages[index + 1];
|
||||||
|
expect(next?.role).toBe('user');
|
||||||
|
const results = next.content.filter((block) => block.type === 'tool_result').map((block) => block.tool_use_id);
|
||||||
|
expect(results).toEqual(ids);
|
||||||
|
});
|
||||||
|
expect(messages.flatMap((message) => message.content).some((block) => block.text === '')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ASTHIS-017: a step that only kept a note is the model call it was, answered with what was kept', () => {
|
||||||
|
const history = toChatHistory(
|
||||||
|
[
|
||||||
|
row('user', [{ type: 'text', text: 'Remember the ferry leaves at 7' }]),
|
||||||
|
row('assistant', [
|
||||||
|
{
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: 'n1',
|
||||||
|
toolName: 'remember_for_trip',
|
||||||
|
args: { note: 'Ferry at 7' },
|
||||||
|
result: { notes: 'Ferry at 7' },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
row('assistant', [{ type: 'text', text: 'Noted.' }]),
|
||||||
|
],
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
expect(history).toEqual([
|
||||||
|
{ role: 'user', text: 'Remember the ferry leaves at 7' },
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
text: '',
|
||||||
|
toolCalls: [{ id: 'n1', name: 'remember_for_trip', args: { note: 'Ferry at 7' } }],
|
||||||
|
providerState: null,
|
||||||
|
},
|
||||||
|
{ role: 'tool', toolCallId: 'n1', name: 'remember_for_trip', output: '{"notes":"Ferry at 7"}' },
|
||||||
|
{ role: 'assistant', text: 'Noted.', toolCalls: [], providerState: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('toThreadMessages', () => {
|
describe('toThreadMessages', () => {
|
||||||
it('ASTHIS-010: the steps of one answer are one message under the first row id', () => {
|
it('ASTHIS-010: the steps of one answer are one message under the first row id', () => {
|
||||||
nextId = 10;
|
nextId = 10;
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ describe('describeToolCall', () => {
|
|||||||
});
|
});
|
||||||
expect(describe_('update_day_note', { dayId: 31, noteId: 41, time: null })).toMatchObject({
|
expect(describe_('update_day_note', { dayId: 31, noteId: 41, time: null })).toMatchObject({
|
||||||
subject: 'Rail pass',
|
subject: 'Rail pass',
|
||||||
rows: [{ op: 'change', labelKey: 'time', before: '09:00', after: '' }],
|
rows: [{ op: 'change', labelKey: 'content', before: '09:00', after: '' }],
|
||||||
});
|
});
|
||||||
expect(describe_('create_day_note', { dayId: 33, text: 'Laundry', icon: '🧺' }).rows).toEqual([
|
expect(describe_('create_day_note', { dayId: 33, text: 'Laundry', icon: '🧺' }).rows).toEqual([
|
||||||
{ op: 'add', labelKey: 'day', before: null, after: 'Spare day' },
|
{ op: 'add', labelKey: 'day', before: null, after: 'Spare day' },
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ describe('what the engine reads', () => {
|
|||||||
|
|
||||||
expect(travel.entries.map((e) => [e.id, e.title, e.mood])).toEqual([[entry.id, 'Alfama', 'amazing']]);
|
expect(travel.entries.map((e) => [e.id, e.title, e.mood])).toEqual([[entry.id, 'Alfama', 'amazing']]);
|
||||||
expect(travel.locale).toBe('de-DE');
|
expect(travel.locale).toBe('de-DE');
|
||||||
expect(travel.labels).toMatchObject({ day: 'TAG {day}', stations: 'Stationen', pros: 'Pro', cons: 'Contra' });
|
expect(travel.labels).toMatchObject({ day: 'TAG {day}', stations: 'Unterwegs', pros: 'Pro', cons: 'Contra' });
|
||||||
expect(travel.path).toEqual([]);
|
expect(travel.path).toEqual([]);
|
||||||
expect((await edit.wordsFor(owner.id)).locale).toBe('de-DE');
|
expect((await edit.wordsFor(owner.id)).locale).toBe('de-DE');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe('the book words', () => {
|
|||||||
expect(french.locale).toBe('fr-FR');
|
expect(french.locale).toBe('fr-FR');
|
||||||
expect(french.labels).toMatchObject({
|
expect(french.labels).toMatchObject({
|
||||||
day: 'JOUR {day}',
|
day: 'JOUR {day}',
|
||||||
stations: 'Étapes',
|
stations: 'En chemin',
|
||||||
summary: 'Résumé',
|
summary: 'Résumé',
|
||||||
countries: 'Pays',
|
countries: 'Pays',
|
||||||
});
|
});
|
||||||
@@ -30,7 +30,7 @@ describe('the book words', () => {
|
|||||||
locale: 'en-US',
|
locale: 'en-US',
|
||||||
labels: {
|
labels: {
|
||||||
day: 'DAY {day}',
|
day: 'DAY {day}',
|
||||||
stations: 'Stations',
|
stations: 'Along the way',
|
||||||
summary: 'Summary',
|
summary: 'Summary',
|
||||||
countries: 'Countries',
|
countries: 'Countries',
|
||||||
distance: 'Distance',
|
distance: 'Distance',
|
||||||
|
|||||||
@@ -96,11 +96,63 @@ describe('LlmLocalService.listModels', () => {
|
|||||||
expect(fetchFn.mock.calls[1][1].headers).toMatchObject({ authorization: 'Bearer k' });
|
expect(fetchFn.mock.calls[1][1].headers).toMatchObject({ authorization: 'Bearer k' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the 404 when the OpenAI model list is missing too', async () => {
|
it('says no model list came, with the first status, when no list exists anywhere', async () => {
|
||||||
mockFetch(async () => ({ ok: false, status: 404, body: null }));
|
const fetchFn = mockFetch(async () => ({ ok: false, status: 404, body: null }));
|
||||||
await expect(svc().listModels('http://nothing:1')).rejects.toMatchObject({
|
await expect(svc().listModels('http://nothing:1')).rejects.toMatchObject({
|
||||||
response: { error: 'Local LLM server error (404)' },
|
code: 'llm_no_model_list',
|
||||||
|
response: { error: 'Local LLM server sent no model list (404)' },
|
||||||
});
|
});
|
||||||
|
expect(fetchFn.mock.calls.map((c: unknown[]) => c[0])).toEqual([
|
||||||
|
'http://nothing:1/api/tags',
|
||||||
|
'http://nothing:1/models',
|
||||||
|
'http://nothing:1/v1/models',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads past an /api/tags that answers 200 without a model list, as LM Studio does (#2808)', async () => {
|
||||||
|
mockFetch(async (url?: string) =>
|
||||||
|
url?.endsWith('/api/tags')
|
||||||
|
? { ok: true, status: 200, json: async () => ({ error: 'Unexpected endpoint or method. (GET /api/tags)' }) }
|
||||||
|
: { ok: true, status: 200, json: async () => ({ data: [{ id: 'qwen3-8b' }] }) },
|
||||||
|
);
|
||||||
|
expect((await svc().listModels('http://lmstudio:1234/v1')).models).toEqual([{ name: 'qwen3-8b', size: 0 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks the address as entered when it does not end in /v1 (#2808)', async () => {
|
||||||
|
const fetchFn = mockFetch(async (url?: string) =>
|
||||||
|
url === 'https://owui.example.org/api/models'
|
||||||
|
? { ok: true, status: 200, json: async () => ({ data: [{ id: 'llama3.3' }] }) }
|
||||||
|
: { ok: false, status: 404, body: null },
|
||||||
|
);
|
||||||
|
expect((await svc().listModels('https://owui.example.org/api/', 'k')).models).toEqual([
|
||||||
|
{ name: 'llama3.3', size: 0 },
|
||||||
|
]);
|
||||||
|
expect(fetchFn.mock.calls.map((c: unknown[]) => c[0])).toEqual([
|
||||||
|
'https://owui.example.org/api/api/tags',
|
||||||
|
'https://owui.example.org/api/models',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a refused key even when the refusal came from a later list', async () => {
|
||||||
|
mockFetch(async (url?: string) =>
|
||||||
|
url?.endsWith('/api/tags') ? { ok: false, status: 404, body: null } : { ok: false, status: 401, body: null },
|
||||||
|
);
|
||||||
|
await expect(svc().listModels('https://api.groq.com/openai/v1', 'bad')).rejects.toMatchObject({
|
||||||
|
code: 'llm_key_refused',
|
||||||
|
response: { error: 'Local LLM server rejected the API key (401)' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a later list that cannot be reached, and a body that is not JSON', async () => {
|
||||||
|
safeFetchLlmMock
|
||||||
|
.mockImplementationOnce(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => Promise.reject(new Error('html')),
|
||||||
|
}))
|
||||||
|
.mockImplementationOnce(() => Promise.reject(new Error('ECONNRESET')))
|
||||||
|
.mockImplementationOnce(async () => ({ ok: true, status: 200, json: async () => ({ data: [{ id: 'm' }] }) }));
|
||||||
|
expect((await svc().listModels('http://vllm:8000')).models).toEqual([{ name: 'm', size: 0 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('502s when the server is unreachable', async () => {
|
it('502s when the server is unreachable', async () => {
|
||||||
|
|||||||
@@ -95,4 +95,79 @@ describe('extractEnforced', () => {
|
|||||||
mockFetch(() => jsonResponse({ error: 'model not found' }, false, 404));
|
mockFetch(() => jsonResponse({ error: 'model not found' }, false, 404));
|
||||||
await expect(extractEnforced(INPUT)).rejects.toThrow(/Ollama \/api\/chat failed \(404\)/);
|
await expect(extractEnforced(INPUT)).rejects.toThrow(/Ollama \/api\/chat failed \(404\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws for a server error without trying another path', async () => {
|
||||||
|
const fetchFn = mockFetch(() => jsonResponse({ error: 'boom' }, false, 500));
|
||||||
|
await expect(extractEnforced(INPUT)).rejects.toThrow(/failed \(500\)/);
|
||||||
|
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractEnforced on an OpenAI-compatible server (#2806)', () => {
|
||||||
|
const GROQ = { ...INPUT, baseUrl: 'https://api.groq.com/openai/v1', model: 'openai/gpt-oss-120b', apiKey: 'gsk' };
|
||||||
|
const chatAnswer = (content: string) => jsonResponse({ choices: [{ message: { content } }] });
|
||||||
|
const unknownUrl = (url: string) =>
|
||||||
|
jsonResponse(
|
||||||
|
{ error: { message: `Unknown request URL: POST ${new URL(url).pathname}.`, code: 'unknown_url' } },
|
||||||
|
false,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
|
||||||
|
it('sends the extraction to /chat/completions at the address as entered when /api/chat is missing', async () => {
|
||||||
|
const fetchFn = mockFetch((url = '') =>
|
||||||
|
url.endsWith('/api/chat') ? unknownUrl(url) : chatAnswer('{"result":[{"name":"Hotel"}]}'),
|
||||||
|
);
|
||||||
|
expect(await extractEnforced(GROQ)).toEqual({ name: 'Hotel' });
|
||||||
|
expect(fetchFn.mock.calls.map((c) => c[0])).toEqual([
|
||||||
|
'https://api.groq.com/openai/api/chat',
|
||||||
|
'https://api.groq.com/openai/v1/chat/completions',
|
||||||
|
]);
|
||||||
|
const init = fetchFn.mock.calls[1][1] as RequestInit;
|
||||||
|
expect((init.headers as Record<string, string>).authorization).toBe('Bearer gsk');
|
||||||
|
const body = JSON.parse(init.body as string);
|
||||||
|
expect(body.model).toBe('openai/gpt-oss-120b');
|
||||||
|
expect(body.response_format.json_schema.schema).toEqual({
|
||||||
|
type: 'object',
|
||||||
|
properties: { result: { type: 'array', items: { type: 'object' } } },
|
||||||
|
required: ['result'],
|
||||||
|
});
|
||||||
|
expect(body.messages[0].content).toContain('{"result": [object]}');
|
||||||
|
expect(body.messages[1].content[0].text).toBe('doc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back on a 405 or 501 too, and on a 200 that is not an Ollama answer (LM Studio)', async () => {
|
||||||
|
const fallsBackAfter = async (first: Response) => {
|
||||||
|
mockFetch((url = '') => (url.endsWith('/api/chat') ? first : chatAnswer('{"result":[{"a":1}]}')));
|
||||||
|
expect(await extractEnforced(GROQ)).toEqual({ a: 1 });
|
||||||
|
};
|
||||||
|
await fallsBackAfter(jsonResponse({}, false, 405));
|
||||||
|
await fallsBackAfter(jsonResponse({}, false, 501));
|
||||||
|
await fallsBackAfter(jsonResponse({ error: 'Unexpected endpoint' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers null when the server returns no object', async () => {
|
||||||
|
mockFetch((url = '') => (url.endsWith('/api/chat') ? unknownUrl(url) : chatAnswer('{"result":[]}')));
|
||||||
|
expect(await extractEnforced(GROQ)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the images on as image parts with their type', async () => {
|
||||||
|
const fetchFn = mockFetch((url = '') =>
|
||||||
|
url.endsWith('/api/chat') ? unknownUrl(url) : chatAnswer('{"result":[{}]}'),
|
||||||
|
);
|
||||||
|
await extractEnforced({ ...GROQ, images: ['iVBORw0K', '/9j/4AAQ', 'UklGRiQA', 'R0lGODlh'] });
|
||||||
|
const parts = JSON.parse((fetchFn.mock.calls[1][1] as RequestInit).body as string).messages[1].content;
|
||||||
|
expect(parts.slice(1).map((p: { image_url: { url: string } }) => p.image_url.url.split(';')[0])).toEqual([
|
||||||
|
'data:image/png',
|
||||||
|
'data:image/jpeg',
|
||||||
|
'data:image/webp',
|
||||||
|
'data:image/gif',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the OpenAI-compatible failure when that path fails too', async () => {
|
||||||
|
mockFetch((url = '') =>
|
||||||
|
url.endsWith('/api/chat') ? unknownUrl(url) : jsonResponse({ error: 'bad model' }, false, 404),
|
||||||
|
);
|
||||||
|
await expect(extractEnforced(GROQ)).rejects.toThrow(/LLM request failed \(404\)/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
COORD_DEDUP_TOLERANCE,
|
COORD_DEDUP_TOLERANCE,
|
||||||
|
dedupSetOf,
|
||||||
externalIdsOf,
|
externalIdsOf,
|
||||||
isPlaceDuplicate,
|
isPlaceDuplicate,
|
||||||
mapWithConcurrency,
|
mapWithConcurrency,
|
||||||
@@ -25,6 +26,29 @@ vi.mock('../../../src/db/database', () => ({
|
|||||||
const emptyDedup = (): DedupSet => ({ names: new Set(), coords: [], externalIds: new Set() });
|
const emptyDedup = (): DedupSet => ({ names: new Set(), coords: [], externalIds: new Set() });
|
||||||
|
|
||||||
describe('isPlaceDuplicate / trackInsertedInDedupSet', () => {
|
describe('isPlaceDuplicate / trackInsertedInDedupSet', () => {
|
||||||
|
it('matches a track only against the tracks of that name, never a sight of that name (#2810)', () => {
|
||||||
|
// The watch named the hike after the gorge, and the gorge is already a place of the trip.
|
||||||
|
const dedup = dedupSetOf([
|
||||||
|
{ name: 'Hokitika Gorge', lat: -42.95, lng: 171.0, route_geometry: null },
|
||||||
|
{ name: ' 008 ', lat: 1, lng: 2, route_geometry: '[[1,2],[3,4]]' },
|
||||||
|
]);
|
||||||
|
expect(isPlaceDuplicate({ name: 'hokitika gorge', lat: -42.9, lng: 171.0, track: true }, dedup)).toBe(false);
|
||||||
|
expect(isPlaceDuplicate({ name: '008', lat: 1, lng: 2, track: true }, dedup)).toBe(true);
|
||||||
|
// A pin still matches every place of its name, a track included.
|
||||||
|
expect(isPlaceDuplicate({ name: 'Hokitika Gorge', lat: null, lng: null }, dedup)).toBe(true);
|
||||||
|
expect(isPlaceDuplicate({ name: '008', lat: null, lng: null }, dedup)).toBe(true);
|
||||||
|
|
||||||
|
// A track imported earlier in the same file counts for the next one.
|
||||||
|
trackInsertedInDedupSet({ name: 'Hokitika Gorge', lat: -42.9, lng: 171.0, track: true }, dedup);
|
||||||
|
expect(isPlaceDuplicate({ name: 'Hokitika gorge ', lat: null, lng: null, track: true }, dedup)).toBe(true);
|
||||||
|
// A set built by hand without tracks matches no track by name.
|
||||||
|
const bare = emptyDedup();
|
||||||
|
trackInsertedInDedupSet({ name: 'Ridge', lat: 1, lng: 2 }, bare);
|
||||||
|
expect(isPlaceDuplicate({ name: 'Ridge', lat: 1, lng: 2, track: true }, bare)).toBe(false);
|
||||||
|
trackInsertedInDedupSet({ name: 'Lake', lat: 1, lng: 2, track: true }, bare);
|
||||||
|
expect([...(bare.trackNames ?? [])]).toEqual(['lake']);
|
||||||
|
});
|
||||||
|
|
||||||
it('matches a named place case- and whitespace-insensitively', () => {
|
it('matches a named place case- and whitespace-insensitively', () => {
|
||||||
const dedup = emptyDedup();
|
const dedup = emptyDedup();
|
||||||
trackInsertedInDedupSet({ name: ' Eiffel Tower ', lat: 1, lng: 2 }, dedup);
|
trackInsertedInDedupSet({ name: ' Eiffel Tower ', lat: 1, lng: 2 }, dedup);
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
* line is written as GPX.
|
* line is written as GPX.
|
||||||
*/
|
*/
|
||||||
import { PlaceImportService } from '../../../src/nest/place-import/place-import.service';
|
import { PlaceImportService } from '../../../src/nest/place-import/place-import.service';
|
||||||
|
import { PlacesService } from '../../../src/nest/places/places.service';
|
||||||
import { TourFileReadService } from '../../../src/nest/tours/tour-file-read.service';
|
import { TourFileReadService } from '../../../src/nest/tours/tour-file-read.service';
|
||||||
import { gpxOfLine, splitGpxTracks } from '../../../src/nest/tours/tour-file-split.helpers';
|
import { gpxOfLine, splitGpxTracks } from '../../../src/nest/tours/tour-file-split.helpers';
|
||||||
import { TourFilesModule } from '../../../src/nest/tours/tour-files.module';
|
import { TourFilesModule } from '../../../src/nest/tours/tour-files.module';
|
||||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||||
|
import { makePlace } from '../../helpers/factories/places';
|
||||||
import { makeTrip } from '../../helpers/factories/trips';
|
import { makeTrip } from '../../helpers/factories/trips';
|
||||||
import { makeUser } from '../../helpers/factories/users';
|
import { makeUser } from '../../helpers/factories/users';
|
||||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||||
@@ -18,7 +20,7 @@ import { TOUR_GPX_TEXT_MAX } from '@trek/shared';
|
|||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
// TOUR-FILE-READ-001 to TOUR-FILE-READ-008
|
// TOUR-FILE-READ-001 to TOUR-FILE-READ-009
|
||||||
|
|
||||||
const db = createSnapshotTestDb();
|
const db = createSnapshotTestDb();
|
||||||
let t: TestModule;
|
let t: TestModule;
|
||||||
@@ -130,6 +132,25 @@ describe('TourFileReadService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('TOUR-FILE-READ-009: a track from before Tours under the same name is proposed, for the import to take over (#2810)', async () => {
|
||||||
|
const trip = await tripId();
|
||||||
|
// 4.x stored a GPX track as a plain place: a line, no tours row.
|
||||||
|
await makePlace(t.get(MikroORM), trip, {
|
||||||
|
name: 'Saturday',
|
||||||
|
lat: 47.5,
|
||||||
|
lng: 11.1,
|
||||||
|
route_geometry: '[[47.5,11.1,800],[47.51,11.1,1000]]',
|
||||||
|
});
|
||||||
|
const rows = t.get(PlaceImportService).readGpx(Buffer.from(TWO_TRACKS, 'utf8'), { importTracks: true });
|
||||||
|
expect((await t.get(PlacesService).gpxDuplicates(String(trip), rows)).some(Boolean)).toBe(true);
|
||||||
|
|
||||||
|
const read = await files.read(trip, Buffer.from(TWO_TRACKS, 'utf8'), 'weekend.gpx');
|
||||||
|
expect(read.tracks.map((track) => [track.name, track.duplicate])).toEqual([
|
||||||
|
['Saturday', false],
|
||||||
|
['Sunday', false],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('TOUR-FILE-READ-008: a track longer than one import takes is named but carries no text', async () => {
|
it('TOUR-FILE-READ-008: a track longer than one import takes is named but carries no text', async () => {
|
||||||
const points = Array.from(
|
const points = Array.from(
|
||||||
{ length: 20_000 },
|
{ length: 20_000 },
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* tracksToAdopt (#2810): which rows of a tour import take over a track that a
|
||||||
|
* GPX import before Tours left as a plain place.
|
||||||
|
*/
|
||||||
|
import { tracksToAdopt } from '../../../src/nest/tours/tour-track-adoption';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const LINE = '[[46.1,11.1,1200],[46.2,11.2,1300]]';
|
||||||
|
const track = (
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
extra: { route_geometry?: string | null; tour_place_id?: number | null } = {},
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
route_geometry: LINE,
|
||||||
|
tour_place_id: null,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const rows = (...names: string[]) => names.map((name) => ({ name }));
|
||||||
|
|
||||||
|
describe('tracksToAdopt', () => {
|
||||||
|
it('TRACK-ADOPT-001: pairs a row with a track of the same name, trimmed and in any case', () => {
|
||||||
|
expect([...tracksToAdopt(rows('GARMIN HIKE'), [track(4, ' Garmin hike ')])]).toEqual([
|
||||||
|
[0, { id: 4, route_geometry: LINE }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRACK-ADOPT-002: gives each track to one row only, oldest first', () => {
|
||||||
|
const adopt = tracksToAdopt(rows('Hike', 'hike', 'HIKE'), [track(9, 'hike'), track(3, 'Hike')]);
|
||||||
|
expect([...adopt]).toEqual([
|
||||||
|
[0, { id: 3, route_geometry: LINE }],
|
||||||
|
[1, { id: 9, route_geometry: LINE }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TRACK-ADOPT-003: leaves out a tour, a place without a line, an unnamed place and an unnamed row', () => {
|
||||||
|
const places = [
|
||||||
|
track(1, 'Ridge', { tour_place_id: 1 }),
|
||||||
|
track(2, 'Cafe', { route_geometry: null }),
|
||||||
|
track(3, ' '),
|
||||||
|
track(4, 'Lake'),
|
||||||
|
];
|
||||||
|
expect([...tracksToAdopt(rows('Ridge', 'Cafe', '', 'Other'), places)]).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,8 +27,8 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|||||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||||
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
||||||
import { accommodationsOver } from '../../helpers/accommodations-service';
|
import { accommodationsOver } from '../../helpers/accommodations-service';
|
||||||
import { createTrip, createUser } from '../../helpers/factories';
|
import { createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||||
import { countRows, findRows } from '../../helpers/factories/rows';
|
import { countRows, findRows, updateRows } from '../../helpers/factories/rows';
|
||||||
import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
|
import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
|
||||||
import { tourStageDays } from '../../helpers/fake-tour-stage-days';
|
import { tourStageDays } from '../../helpers/fake-tour-stage-days';
|
||||||
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
||||||
@@ -86,7 +86,7 @@ const mixedGpx = Buffer.from(`<gpx>
|
|||||||
</trkseg><trkseg><trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt></trkseg></trk>
|
</trkseg><trkseg><trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt></trkseg></trk>
|
||||||
</gpx>`);
|
</gpx>`);
|
||||||
|
|
||||||
const TRIGGERS = ['fail_facet', 'fail_place', 'fail_color'];
|
const TRIGGERS = ['fail_facet', 'fail_place', 'fail_color', 'fail_adopt'];
|
||||||
|
|
||||||
/** The collaborator set the container hands PlacesService (the places.service.test.ts builder). */
|
/** The collaborator set the container hands PlacesService (the places.service.test.ts builder). */
|
||||||
async function makePlacesService(): Promise<PlacesService> {
|
async function makePlacesService(): Promise<PlacesService> {
|
||||||
@@ -314,6 +314,21 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
|
|||||||
await expectEmpty();
|
await expectEmpty();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rolls the new places back too when taking over a track from before Tours fails (#2810)', async () => {
|
||||||
|
const legacy = createPlace(testDb, Number(tripId), { name: 'Ridge' }).id;
|
||||||
|
await updateRows(
|
||||||
|
await sharedTestOrm(testDb),
|
||||||
|
Places,
|
||||||
|
{ id: legacy },
|
||||||
|
{ route_geometry: '[[49,12,100],[49.01,12.01,150]]' },
|
||||||
|
);
|
||||||
|
testDb.exec(`CREATE TRIGGER fail_adopt BEFORE INSERT ON tours WHEN NEW.place_id = ${legacy}
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'adopt failure'); END;`);
|
||||||
|
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('adopt failure');
|
||||||
|
expect(await Promise.all([count('places'), count('tours')])).toEqual([1, 0]);
|
||||||
|
expect(broadcast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('rolls back carriers and earlier colors when later coloring fails', async () => {
|
it('rolls back carriers and earlier colors when later coloring fails', async () => {
|
||||||
testDb.exec(`CREATE TRIGGER fail_color BEFORE UPDATE OF route_color ON places WHEN NEW.name = 'Ridge'
|
testDb.exec(`CREATE TRIGGER fail_color BEFORE UPDATE OF route_color ON places WHEN NEW.name = 'Ridge'
|
||||||
BEGIN SELECT RAISE(ABORT, 'color failure'); END;`);
|
BEGIN SELECT RAISE(ABORT, 'color failure'); END;`);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
hostSatisfies,
|
hostSatisfies,
|
||||||
trekRangeBypassed,
|
trekRangeBypassed,
|
||||||
bypassedRange,
|
bypassedRange,
|
||||||
|
acceptanceHolds,
|
||||||
__resetHostWarningForTests,
|
__resetHostWarningForTests,
|
||||||
} from '../../../src/nest/plugins/install/host-compat';
|
} from '../../../src/nest/plugins/install/host-compat';
|
||||||
|
|
||||||
@@ -125,6 +126,27 @@ describe('hostSatisfies', () => {
|
|||||||
* the truth (hostSatisfies) never changes — the UI still has to say the plugin is
|
* the truth (hostSatisfies) never changes — the UI still has to say the plugin is
|
||||||
* outside its range.
|
* outside its range.
|
||||||
*/
|
*/
|
||||||
|
describe('acceptanceHolds', () => {
|
||||||
|
it('holds for the same plugin version on the same TREK major only', () => {
|
||||||
|
process.env.APP_VERSION = '5.0.1';
|
||||||
|
const given = { acceptedVersion: '1.0.3', acceptedHost: '5.0.0' };
|
||||||
|
expect(acceptanceHolds('1.0.3', given)).toBe(true);
|
||||||
|
expect(acceptanceHolds('1.1.0', given)).toBe(false); // an update asks again
|
||||||
|
process.env.APP_VERSION = '6.0.0';
|
||||||
|
expect(acceptanceHolds('1.0.3', given)).toBe(false); // so does a new TREK major
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds nothing without a full consent or a version to compare', () => {
|
||||||
|
process.env.APP_VERSION = '5.0.0';
|
||||||
|
expect(acceptanceHolds('1.0.0', null)).toBe(false);
|
||||||
|
expect(acceptanceHolds('1.0.0', { acceptedVersion: '1.0.0', acceptedHost: null })).toBe(false);
|
||||||
|
expect(acceptanceHolds(null, { acceptedVersion: '1.0.0', acceptedHost: '5.0.0' })).toBe(false);
|
||||||
|
expect(acceptanceHolds('1.0.0', { acceptedVersion: '1.0.0', acceptedHost: 'not-a-version' })).toBe(false);
|
||||||
|
process.env.APP_VERSION = 'dev';
|
||||||
|
expect(acceptanceHolds('1.0.0', { acceptedVersion: '1.0.0', acceptedHost: '5.0.0' })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
|
describe('range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
|
||||||
it('is off by default and only an explicit truthy value turns it on', () => {
|
it('is off by default and only an explicit truthy value turns it on', () => {
|
||||||
expect(trekRangeBypassed()).toBe(false);
|
expect(trekRangeBypassed()).toBe(false);
|
||||||
@@ -138,15 +160,27 @@ describe('range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
|
|||||||
process.env.APP_VERSION = '3.3.0';
|
process.env.APP_VERSION = '3.3.0';
|
||||||
expect(bypassedRange('>=4.0.0')).toBeNull(); // off → the gate refuses, nothing to report
|
expect(bypassedRange('>=4.0.0')).toBeNull(); // off → the gate refuses, nothing to report
|
||||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = 'true';
|
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = 'true';
|
||||||
expect(bypassedRange('>=4.0.0')).toEqual({ trekRange: '>=4.0.0', hostVersion: '3.3.0' });
|
expect(bypassedRange('>=4.0.0')).toEqual({ trekRange: '>=4.0.0', hostVersion: '3.3.0', acceptedByAdmin: false });
|
||||||
expect(bypassedRange('>=3.2.0 <4.0.0')).toBeNull(); // fits → not a bypass, a normal install
|
expect(bypassedRange('>=3.2.0 <4.0.0')).toBeNull(); // fits → not a bypass, a normal install
|
||||||
});
|
});
|
||||||
|
|
||||||
it('covers a plugin that declared no range at all — the same author oversight', () => {
|
it('covers a plugin that declared no range at all — the same author oversight', () => {
|
||||||
process.env.APP_VERSION = '3.3.0';
|
process.env.APP_VERSION = '3.3.0';
|
||||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||||
expect(bypassedRange(null)).toEqual({ trekRange: null, hostVersion: '3.3.0' });
|
expect(bypassedRange(null)).toEqual({ trekRange: null, hostVersion: '3.3.0', acceptedByAdmin: false });
|
||||||
expect(bypassedRange(undefined)).toEqual({ trekRange: null, hostVersion: '3.3.0' });
|
expect(bypassedRange(undefined)).toEqual({ trekRange: null, hostVersion: '3.3.0', acceptedByAdmin: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an admin's consent for one plugin lets it through without the switch, and says it was theirs", () => {
|
||||||
|
process.env.APP_VERSION = '5.0.0';
|
||||||
|
expect(bypassedRange('>=3.2.0 <4.0.0', true)).toEqual({
|
||||||
|
trekRange: '>=3.2.0 <4.0.0',
|
||||||
|
hostVersion: '5.0.0',
|
||||||
|
acceptedByAdmin: true,
|
||||||
|
});
|
||||||
|
expect(bypassedRange('>=5.0.0', true)).toBeNull(); // fits: nothing was set aside
|
||||||
|
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||||
|
expect(bypassedRange('>=3.2.0 <4.0.0', true)).toMatchObject({ acceptedByAdmin: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves hostSatisfies telling the truth while bypassed', () => {
|
it('leaves hostSatisfies telling the truth while bypassed', () => {
|
||||||
|
|||||||
@@ -134,10 +134,32 @@ describe('PluginsService.list', () => {
|
|||||||
expect(out.ignoreTrekRange).toBe(true);
|
expect(out.ignoreTrekRange).toBe(true);
|
||||||
expect(out.plugins[0]).toMatchObject({
|
expect(out.plugins[0]).toMatchObject({
|
||||||
dependencyStatus: 'ok',
|
dependencyStatus: 'ok',
|
||||||
trekRangeBypassed: { trekRange: '>=3.0.0 <4.0.0', hostVersion: '4.1.0' },
|
trekRangeBypassed: { trekRange: '>=3.0.0 <4.0.0', hostVersion: '4.1.0', acceptedByAdmin: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("an admin's consent for this version lets the outgrown plugin activate and names it as theirs", async () => {
|
||||||
|
process.env.APP_VERSION = '4.1.0';
|
||||||
|
await insertRow(orm, Plugins, {
|
||||||
|
id: 'old',
|
||||||
|
name: 'Old',
|
||||||
|
type: 'widget',
|
||||||
|
status: 'inactive',
|
||||||
|
version: '1.0.0',
|
||||||
|
trek_range: '>=3.0.0 <4.0.0',
|
||||||
|
trek_range_accepted_version: '1.0.0',
|
||||||
|
trek_range_accepted_host: '4.0.0',
|
||||||
|
});
|
||||||
|
const out = await (await makeService()).list();
|
||||||
|
expect(out.ignoreTrekRange).toBe(false);
|
||||||
|
expect(out.plugins[0]).toMatchObject({
|
||||||
|
dependencyStatus: 'ok',
|
||||||
|
trekRangeBypassed: { trekRange: '>=3.0.0 <4.0.0', hostVersion: '4.1.0', acceptedByAdmin: true },
|
||||||
|
});
|
||||||
|
// The consent stays on the server: the list does not hand its columns out.
|
||||||
|
expect(out.plugins[0]).not.toHaveProperty('trek_range_accepted_version');
|
||||||
|
});
|
||||||
|
|
||||||
it('a plugin inside its range carries no marker even with the switch on', async () => {
|
it('a plugin inside its range carries no marker even with the switch on', async () => {
|
||||||
process.env.APP_VERSION = '3.5.0';
|
process.env.APP_VERSION = '3.5.0';
|
||||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ export const ASSISTANT_CHANGE_LABEL_KEYS = [
|
|||||||
'currency',
|
'currency',
|
||||||
'stopType',
|
'stopType',
|
||||||
'text',
|
'text',
|
||||||
|
// A day note's heading and its body; `text` and `time` stay for rows already stored.
|
||||||
|
'title',
|
||||||
|
'content',
|
||||||
'icon',
|
'icon',
|
||||||
'color',
|
'color',
|
||||||
'position',
|
'position',
|
||||||
|
|||||||
@@ -646,7 +646,17 @@ const admin: TranslationStrings = {
|
|||||||
'لا تعلن «{name}» أي إصدارات TREK تدعمها؛ يعمل هذا الخادم بالإصدار {host}. يسمح TREK بذلك فقط لأن TREK_PLUGINS_IGNORE_TREK_RANGE مضبوط. لا شيء يدل على أن المطوّر اختبرها على هذا الإصدار من TREK، لذا لا يوجد ضمان أنها ستعمل — وفي حالات نادرة قد تُتلف إضافة غير متوافقة بيانات TREK. تابع فقط إذا كنت تقبل هذه المخاطرة.',
|
'لا تعلن «{name}» أي إصدارات TREK تدعمها؛ يعمل هذا الخادم بالإصدار {host}. يسمح TREK بذلك فقط لأن TREK_PLUGINS_IGNORE_TREK_RANGE مضبوط. لا شيء يدل على أن المطوّر اختبرها على هذا الإصدار من TREK، لذا لا يوجد ضمان أنها ستعمل — وفي حالات نادرة قد تُتلف إضافة غير متوافقة بيانات TREK. تابع فقط إذا كنت تقبل هذه المخاطرة.',
|
||||||
'admin.plugins.dep.trekBypassed': 'خارج نطاق TREK الخاص بها ({range}) — فحوصات الإصدار متوقفة',
|
'admin.plugins.dep.trekBypassed': 'خارج نطاق TREK الخاص بها ({range}) — فحوصات الإصدار متوقفة',
|
||||||
'admin.plugins.dep.trekBypassedUnknown': 'لا تعلن أي نطاق TREK — فحوصات الإصدار متوقفة',
|
'admin.plugins.dep.trekBypassedUnknown': 'لا تعلن أي نطاق TREK — فحوصات الإصدار متوقفة',
|
||||||
'admin.plugins.incompatible': 'غير متوافق',
|
'admin.plugins.rangeBypass.titleAdmin': 'غير مختبَر على هذا الإصدار من TREK',
|
||||||
|
'admin.plugins.rangeBypass.bodyAdmin':
|
||||||
|
'بحسب مطوّره، يدعم «{name}» إصدارات TREK {range}، بينما يعمل هذا الخادم بالإصدار {host}. لم يؤكد المطوّر توافقه مع هذا الإصدار بعد، لذا قد لا يعمل، وفي حالات نادرة قد تتلف إضافة غير متوافقة بيانات TREK. يمكنك استخدامها رغم ذلك: يسري اختيارك على هذه الإضافة في هذا الإصدار فقط، وسيسألك TREK مجددًا بعد أي تحديث.',
|
||||||
|
'admin.plugins.rangeBypass.bodyAdminUnknown':
|
||||||
|
'لا يذكر «{name}» إصدارات TREK التي يدعمها، بينما يعمل هذا الخادم بالإصدار {host}. لا شيء يشير إلى أنه اختُبر على هذا الإصدار، لذا قد لا يعمل، وفي حالات نادرة قد تتلف إضافة غير متوافقة بيانات TREK. يمكنك استخدامها رغم ذلك: يسري اختيارك على هذه الإضافة في هذا الإصدار فقط، وسيسألك TREK مجددًا بعد أي تحديث.',
|
||||||
|
'admin.plugins.dep.trekAccepted': 'مصمَّمة لـ TREK {range}، تعمل على مسؤوليتك',
|
||||||
|
'admin.plugins.dep.trekAcceptedUnknown': 'لا يوجد إصدار TREK محدد، تعمل على مسؤوليتك',
|
||||||
|
'admin.plugins.activateAnyway': 'تفعيل رغم ذلك',
|
||||||
|
'admin.plugins.untestedChip': 'لم يُؤكَّد توافقه مع TREK {host} بعد',
|
||||||
|
'admin.plugins.filters': 'تصفية',
|
||||||
|
'admin.plugins.security.short': 'كيف تُحمى الإضافات',
|
||||||
'admin.plugins.accessTitle': 'ما الذي يمكنها الوصول إليه',
|
'admin.plugins.accessTitle': 'ما الذي يمكنها الوصول إليه',
|
||||||
'admin.plugins.connectsTitle': 'يتصل بـ',
|
'admin.plugins.connectsTitle': 'يتصل بـ',
|
||||||
'admin.plugins.detailsTitle': 'التفاصيل',
|
'admin.plugins.detailsTitle': 'التفاصيل',
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.connect.openAssistant': 'فتح المساعد',
|
'assistant.connect.openAssistant': 'فتح المساعد',
|
||||||
'assistant.connect.badKey': 'هذا المفتاح لا يعمل.',
|
'assistant.connect.badKey': 'هذا المفتاح لا يعمل.',
|
||||||
'assistant.connect.unknownModel': 'هذا النموذج غير متاح هنا.',
|
'assistant.connect.unknownModel': 'هذا النموذج غير متاح هنا.',
|
||||||
|
'assistant.connect.noModelList':
|
||||||
|
'استجاب الخادم، لكنه لم يرسل قائمة بالنماذج. تحقّق من العنوان، فهو ينتهي عادةً بـ /v1.',
|
||||||
'assistant.connect.getKey': 'الحصول على مفتاح',
|
'assistant.connect.getKey': 'الحصول على مفتاح',
|
||||||
'assistant.connect.hintAnthropic': 'تتضمن باقتا Claude Max وTeam رصيدًا لـ API.',
|
'assistant.connect.hintAnthropic': 'تتضمن باقتا Claude Max وTeam رصيدًا لـ API.',
|
||||||
'assistant.connect.hintOpenai': 'أنشئ مفتاحًا في لوحة تحكم OpenAI.',
|
'assistant.connect.hintOpenai': 'أنشئ مفتاحًا في لوحة تحكم OpenAI.',
|
||||||
@@ -189,6 +191,8 @@ const assistant: TranslationStrings = {
|
|||||||
'assistant.change.label.currency': 'العملة',
|
'assistant.change.label.currency': 'العملة',
|
||||||
'assistant.change.label.stopType': 'نوع التوقف',
|
'assistant.change.label.stopType': 'نوع التوقف',
|
||||||
'assistant.change.label.text': 'النص',
|
'assistant.change.label.text': 'النص',
|
||||||
|
'assistant.change.label.title': 'العنوان',
|
||||||
|
'assistant.change.label.content': 'المحتوى',
|
||||||
'assistant.change.label.icon': 'الأيقونة',
|
'assistant.change.label.icon': 'الأيقونة',
|
||||||
'assistant.change.label.color': 'اللون',
|
'assistant.change.label.color': 'اللون',
|
||||||
'assistant.change.label.position': 'الترتيب',
|
'assistant.change.label.position': 'الترتيب',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user