mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-05 04:04:07 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aee275e2b0 | |||
| ef86bf2660 | |||
| 0139c46fb4 | |||
| a0a7fcdbb9 | |||
| 186dd94b61 | |||
| ad721a1ac6 | |||
| 87441f269d | |||
| a0943f8226 | |||
| 6741e8ca0d | |||
| c14f179e39 | |||
| 3bfef73a46 | |||
| 7e790c3fb7 | |||
| 228db8541c | |||
| a729a6935f | |||
| 6c0e39e287 | |||
| 22574a2270 | |||
| 1d880cb881 | |||
| 3d076704f9 | |||
| 1b0f043506 | |||
| c383b11648 | |||
| fed564e7c0 | |||
| 9e216f5ae4 | |||
| 0f0232491b | |||
| 35fb59e64e | |||
| ee3fff1dab | |||
| 3bc75d2b1c | |||
| 094426debb | |||
| 313198ba63 | |||
| e677d58f91 | |||
| bc60aea839 | |||
| b5cd951bd2 | |||
| fd818050bd | |||
| 0313597b10 | |||
| 2c867673bf | |||
| 39ebf0976c | |||
| 1ecc449f3e | |||
| b8990782cc |
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 5.0.0
|
||||
version: 5.0.1
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "5.0.0"
|
||||
appVersion: "5.0.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 382,
|
||||
"@typescript-eslint/no-misused-promises": 849,
|
||||
"@typescript-eslint/no-misused-promises": 848,
|
||||
"no-console": 88,
|
||||
"complexity": 220,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
@@ -30,7 +30,7 @@
|
||||
"no-useless-escape": 1
|
||||
},
|
||||
"suppressed": {
|
||||
"@typescript-eslint/no-explicit-any": 40,
|
||||
"@typescript-eslint/no-explicit-any": 38,
|
||||
"react-hooks/exhaustive-deps": 46
|
||||
},
|
||||
"tests": {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1385,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2290,
|
||||
"src/components/Map/MapViewGL.tsx": 2218,
|
||||
"src/components/Map/RouteCalculator.ts": 1063,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
|
||||
@@ -440,6 +440,8 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
||||
currency: { labelKey: 'assistant.change.label.currency' },
|
||||
stopType: { labelKey: 'assistant.change.label.stopType' },
|
||||
text: { labelKey: 'assistant.change.label.text' },
|
||||
title: { labelKey: 'assistant.change.label.title' },
|
||||
content: { labelKey: 'assistant.change.label.content' },
|
||||
icon: { labelKey: 'assistant.change.label.icon' },
|
||||
color: { labelKey: 'assistant.change.label.color' },
|
||||
position: { labelKey: 'assistant.change.label.position' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-030
|
||||
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-032
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import en from '@trek/shared/i18n/en'
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
|
||||
@@ -66,6 +66,7 @@ function renderCard(
|
||||
canManage?: boolean
|
||||
providerName?: string
|
||||
onReconnect?: () => void
|
||||
onEditConnection?: () => void
|
||||
} = {},
|
||||
) {
|
||||
const utils = render(
|
||||
@@ -75,6 +76,7 @@ function renderCard(
|
||||
sync={makeSync(opts.sync)}
|
||||
canManage={opts.canManage ?? true}
|
||||
onReconnect={opts.onReconnect}
|
||||
onEditConnection={opts.onEditConnection}
|
||||
/>,
|
||||
)
|
||||
const header = utils.container.querySelector('header') as HTMLElement
|
||||
@@ -421,3 +423,31 @@ describe('DocSyncBinding: the direction lanes', () => {
|
||||
expect(updateLink).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DocSyncBinding: the connection behind it', () => {
|
||||
const S = en as unknown as Record<string, string>
|
||||
const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] })
|
||||
|
||||
it('FE-DOCSYNC-BIND-031: the settings open the stored connection at any time, not only after a refusal', () => {
|
||||
const onEditConnection = vi.fn()
|
||||
renderCard({ link: { lastSyncState: 'ok' }, onEditConnection })
|
||||
|
||||
expect(editButton()).not.toBeInTheDocument()
|
||||
openSettings()
|
||||
expect(screen.getByText(S['docsync.connection.title'])).toBeInTheDocument()
|
||||
fireEvent.click(editButton() as HTMLElement)
|
||||
|
||||
expect(onEditConnection).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-BIND-032: a member, or a panel without the form, gets no way to the connection', () => {
|
||||
const member = renderCard({ canManage: false, onEditConnection: vi.fn() })
|
||||
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
|
||||
expect(editButton()).not.toBeInTheDocument()
|
||||
member.unmount()
|
||||
|
||||
renderCard()
|
||||
openSettings()
|
||||
expect(editButton()).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, Link2Off, RefreshCw,
|
||||
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, KeyRound, Link2Off, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
@@ -27,6 +27,7 @@ export default function DocSyncBinding({
|
||||
sync,
|
||||
canManage,
|
||||
onReconnect,
|
||||
onEditConnection,
|
||||
}: {
|
||||
link: DocSyncLink
|
||||
providerName: string
|
||||
@@ -38,6 +39,8 @@ export default function DocSyncBinding({
|
||||
* not carry; left out, a refused credential is reported but not curable.
|
||||
*/
|
||||
onReconnect?: () => void
|
||||
/** The same form at any time, to change the address or the login, or to remove the connection. */
|
||||
onEditConnection?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -237,6 +240,21 @@ export default function DocSyncBinding({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onEditConnection && (
|
||||
<Row label={t('docsync.connection.title')} hint={t('docsync.connection.hint')}>
|
||||
<span className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditConnection}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<KeyRound size={13} />
|
||||
{t('docsync.connection.edit')}
|
||||
</button>
|
||||
</span>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-027
|
||||
// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-030
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import en from '@trek/shared/i18n/en'
|
||||
import { render, screen, fireEvent, waitFor, act, within } from '../../../../tests/helpers/render'
|
||||
@@ -13,6 +13,7 @@ const listLinks = vi.fn(async (_tripId: number | string): Promise<unknown> => []
|
||||
const status = vi.fn(async (_tripId: number | string): Promise<unknown> => ({ items: {}, links: [] }))
|
||||
const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ id: 1 }))
|
||||
const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
|
||||
const deleteConnection = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
|
||||
|
||||
vi.mock('../../../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../api/client')>()
|
||||
@@ -26,6 +27,7 @@ vi.mock('../../../api/client', async (importOriginal) => {
|
||||
listLinks: (tripId: number | string) => listLinks(tripId),
|
||||
saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data),
|
||||
testConnection: (tripId: number | string, data: unknown) => testConnection(tripId, data),
|
||||
deleteConnection: (tripId: number | string, id: number) => deleteConnection(tripId, id),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -81,11 +83,13 @@ function Harness({
|
||||
provider,
|
||||
onClose,
|
||||
onConnected,
|
||||
onRemoved,
|
||||
waitForConnection,
|
||||
}: {
|
||||
provider: DocSyncProvider
|
||||
onClose: () => void
|
||||
onConnected: (providerId: string) => void
|
||||
onRemoved?: () => void
|
||||
/**
|
||||
* Hold the dialog back until the stored connection is in hand, the way the
|
||||
* panel does: it only offers "connect" once it knows what is already there.
|
||||
@@ -94,7 +98,9 @@ function Harness({
|
||||
}) {
|
||||
const sync = useDocSync(TRIP_ID, true)
|
||||
if (waitForConnection && !sync.connectionFor(provider.id)) return null
|
||||
return <DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} />
|
||||
return (
|
||||
<DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} onRemoved={onRemoved} />
|
||||
)
|
||||
}
|
||||
|
||||
/** Let the hook's initial load land before anything is asserted. */
|
||||
@@ -492,3 +498,52 @@ describe('DocSyncConnectModal with a connection already stored', () => {
|
||||
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ baseUrl: 'https://cloud.example.com' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('DocSyncConnectModal removal', () => {
|
||||
const removeButton = () => screen.queryByRole('button', { name: tx('docsync.connection.remove') })
|
||||
// The confirmation is portalled after the dialog, so its button is the last one of that name.
|
||||
const confirmRemoval = () => {
|
||||
const named = screen.getAllByRole('button', { name: tx('docsync.connection.remove') })
|
||||
return named[named.length - 1]
|
||||
}
|
||||
|
||||
async function openWithRemoval(stored: boolean) {
|
||||
if (stored) listConnections.mockResolvedValue([connection()])
|
||||
const onRemoved = vi.fn()
|
||||
render(<Harness provider={nextcloud} onClose={vi.fn()} onConnected={vi.fn()} onRemoved={onRemoved} waitForConnection={stored} />)
|
||||
await flush()
|
||||
return onRemoved
|
||||
}
|
||||
|
||||
it('FE-DOCSYNC-CONNECT-028: a stored connection can be removed after a confirmation, and the dialog is told', async () => {
|
||||
const onRemoved = await openWithRemoval(true)
|
||||
await waitFor(() => expect(removeButton()).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: tx('docsync.connection.remove') }))
|
||||
expect(deleteConnection).not.toHaveBeenCalled()
|
||||
await screen.findByText(tx('docsync.connection.confirmRemove'))
|
||||
fireEvent.click(confirmRemoval())
|
||||
|
||||
await waitFor(() => expect(deleteConnection).toHaveBeenCalledWith(TRIP_ID, 9))
|
||||
await waitFor(() => expect(onRemoved).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-CONNECT-029: a refused removal keeps the dialog and says why', async () => {
|
||||
deleteConnection.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
|
||||
const onRemoved = await openWithRemoval(true)
|
||||
await waitFor(() => expect(removeButton()).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: tx('docsync.connection.remove') }))
|
||||
await screen.findByText(tx('docsync.connection.confirmRemove'))
|
||||
fireEvent.click(confirmRemoval())
|
||||
|
||||
await waitFor(() => expect(deleteConnection).toHaveBeenCalled())
|
||||
expect(onRemoved).not.toHaveBeenCalled()
|
||||
expect(await screen.findByText(tx('docsync.error.forbidden'))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-CONNECT-030: a first connection has nothing to remove', async () => {
|
||||
await openWithRemoval(false)
|
||||
expect(removeButton()).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||
import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../../shared/Tooltip'
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
@@ -22,11 +23,14 @@ export default function DocSyncConnectModal({
|
||||
sync,
|
||||
onClose,
|
||||
onConnected,
|
||||
onRemoved,
|
||||
}: {
|
||||
provider: DocSyncProvider
|
||||
sync: ReturnType<typeof useDocSync>
|
||||
onClose: () => void
|
||||
onConnected: (providerId: string) => void
|
||||
/** After the stored connection was removed from here; left out, the form offers no removal. */
|
||||
onRemoved?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const existing = sync.connectionFor(provider.id)
|
||||
@@ -34,6 +38,11 @@ export default function DocSyncConnectModal({
|
||||
|
||||
const form = useConnectForm(provider, existing, sync)
|
||||
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState(false)
|
||||
|
||||
const remove = async () => {
|
||||
if (existing && (await sync.removeConnection(existing.id))) onRemoved?.()
|
||||
}
|
||||
|
||||
const test = async () => setVerdict(await form.probe())
|
||||
const labelId = useId()
|
||||
@@ -46,6 +55,7 @@ export default function DocSyncConnectModal({
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
blocked={confirmRemove}
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile>{Icon ? <Icon className="h-5 w-5 text-content" /> : <Plug size={20} strokeWidth={1.9} className="text-content-muted" />}</DialogTile>}
|
||||
@@ -57,6 +67,13 @@ export default function DocSyncConnectModal({
|
||||
)}
|
||||
footer={(
|
||||
<DialogFooter>
|
||||
{existing && onRemoved && (
|
||||
<DeleteButton
|
||||
label={t('docsync.connection.remove')}
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
disabled={sync.busy === 'connection'}
|
||||
/>
|
||||
)}
|
||||
<Verdict verdict={verdict} busy={sync.busy === 'test'} failure={sync.error} />
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={() => void test()} disabled={!form.complete || sync.busy === 'test'}>
|
||||
@@ -101,6 +118,15 @@ export default function DocSyncConnectModal({
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmRemove}
|
||||
onClose={() => setConfirmRemove(false)}
|
||||
onConfirm={() => void remove()}
|
||||
title={t('docsync.connection.remove')}
|
||||
message={t('docsync.connection.confirmRemove')}
|
||||
confirmLabel={t('docsync.connection.remove')}
|
||||
/>
|
||||
</DialogShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ export default function DocSyncPanel({
|
||||
setSelected(prev => (sync.links.some(l => l.id === prev) ? prev : (sync.links[0]?.id ?? null)))
|
||||
}, [sync.links])
|
||||
|
||||
// The connection behind the folder picker, which the picker can send back to.
|
||||
const scopeProvider = scopeFor ? sync.providers.find(p => p.id === scopeFor) : undefined
|
||||
|
||||
const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0)
|
||||
const labelId = useId()
|
||||
|
||||
@@ -113,6 +116,7 @@ export default function DocSyncPanel({
|
||||
sync={sync}
|
||||
canManage={canManage}
|
||||
onReconnect={activeProvider ? () => setReconnecting(activeProvider) : undefined}
|
||||
onEditConnection={canManage && activeProvider ? () => setReconnecting(activeProvider) : undefined}
|
||||
/>
|
||||
{/* Every action in this column reports here: a refused run, a
|
||||
switch that flipped back, a conflict the server would not
|
||||
@@ -141,6 +145,7 @@ export default function DocSyncPanel({
|
||||
sync={sync}
|
||||
onClose={() => setConnecting(null)}
|
||||
onConnected={id => { setConnecting(null); setScopeFor(id) }}
|
||||
onRemoved={() => setConnecting(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -149,6 +154,7 @@ export default function DocSyncPanel({
|
||||
provider={reconnecting}
|
||||
sync={sync}
|
||||
onClose={() => setReconnecting(null)}
|
||||
onRemoved={() => setReconnecting(null)}
|
||||
onConnected={id => {
|
||||
setReconnecting(null)
|
||||
// Run straight away, as binding does: the card still reports the
|
||||
@@ -168,6 +174,7 @@ export default function DocSyncPanel({
|
||||
sync={sync}
|
||||
onClose={() => setScopeFor(null)}
|
||||
onBound={() => setScopeFor(null)}
|
||||
onEditConnection={scopeProvider ? () => { setScopeFor(null); setConnecting(scopeProvider) } : undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-023
|
||||
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-024
|
||||
|
||||
/**
|
||||
* The folder picker of the document-sync dialog.
|
||||
@@ -84,9 +84,10 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }: {
|
||||
function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless', onEditConnection }: {
|
||||
suggestedName?: string
|
||||
providerName?: string
|
||||
onEditConnection?: () => void
|
||||
}) {
|
||||
const sync = useDocSync(TRIP_ID, false)
|
||||
return (
|
||||
@@ -97,6 +98,7 @@ function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }
|
||||
sync={sync}
|
||||
onClose={onClose}
|
||||
onBound={onBound}
|
||||
onEditConnection={onEditConnection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -381,3 +383,20 @@ describe('DocSyncScopeModal errors', () => {
|
||||
expect(createButton()).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DocSyncScopeModal connection', () => {
|
||||
it('FE-DOCSYNC-SCOPE-024: leads back to the stored connection before a folder is chosen', async () => {
|
||||
const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] })
|
||||
const onEditConnection = vi.fn()
|
||||
const first = render(<Harness onEditConnection={onEditConnection} />)
|
||||
await waitFor(() => expect(listScopes).toHaveBeenCalled())
|
||||
|
||||
fireEvent.click(editButton() as HTMLElement)
|
||||
expect(onEditConnection).toHaveBeenCalledTimes(1)
|
||||
expect(createLink).not.toHaveBeenCalled()
|
||||
first.unmount()
|
||||
|
||||
render(<Harness />)
|
||||
expect(editButton()).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import { FolderPlus, Loader2, Search } from 'lucide-react'
|
||||
import { FolderPlus, KeyRound, Loader2, Search } from 'lucide-react'
|
||||
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import type { DocSyncConnection, useDocSync } from './useDocSync'
|
||||
@@ -22,6 +22,7 @@ export default function DocSyncScopeModal({
|
||||
sync,
|
||||
onClose,
|
||||
onBound,
|
||||
onEditConnection,
|
||||
}: {
|
||||
connection: DocSyncConnection
|
||||
providerName: string
|
||||
@@ -30,6 +31,8 @@ export default function DocSyncScopeModal({
|
||||
sync: ReturnType<typeof useDocSync>
|
||||
onClose: () => void
|
||||
onBound: () => void
|
||||
/** Back to the stored connection, to change the login or remove it before choosing a folder. */
|
||||
onEditConnection?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const labelId = useId()
|
||||
@@ -63,7 +66,19 @@ export default function DocSyncScopeModal({
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
|
||||
{onEditConnection && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditConnection}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-edge px-2.5 py-1 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<KeyRound size={13} />
|
||||
{t('docsync.connection.edit')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-edge bg-surface-secondary p-3">
|
||||
<span className="mb-2 block text-body font-medium text-content">{t('docsync.scope.createTitle')}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-030
|
||||
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-032
|
||||
|
||||
/**
|
||||
* The document-sync hook.
|
||||
@@ -22,6 +22,7 @@ const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown)
|
||||
const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise<unknown> => ({}))
|
||||
const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({}))
|
||||
const listScopesApi = vi.fn(async (_tripId: number | string, _connectionId: number, _q?: string): Promise<unknown> => ({ scopes: [] }))
|
||||
const deleteConnectionApi = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
|
||||
const resolveApi = vi.fn(async (_tripId: number | string, _itemId: number, _keep: string): Promise<unknown> => ({ success: true }))
|
||||
|
||||
vi.mock('../../../api/client', async (importOriginal) => {
|
||||
@@ -40,6 +41,7 @@ vi.mock('../../../api/client', async (importOriginal) => {
|
||||
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full),
|
||||
listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopesApi(tripId, connectionId, q),
|
||||
resolve: (tripId: number | string, itemId: number, keep: string) => resolveApi(tripId, itemId, keep),
|
||||
deleteConnection: (tripId: number | string, id: number) => deleteConnectionApi(tripId, id),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -126,6 +128,7 @@ beforeEach(() => {
|
||||
syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
|
||||
listScopesApi.mockResolvedValue({ scopes: [] })
|
||||
resolveApi.mockResolvedValue({ success: true })
|
||||
deleteConnectionApi.mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
describe('useDocSync initial load', () => {
|
||||
@@ -471,6 +474,38 @@ describe('useDocSync resolveConflict', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDocSync removeConnection', () => {
|
||||
it('FE-DOCSYNC-HOOK-031: forgets the connection and reloads, so its bindings leave the screen too', async () => {
|
||||
const { result } = await mountLoaded()
|
||||
expect(result.current.links).toHaveLength(1)
|
||||
// The server drops the bindings with the connection (ON DELETE CASCADE).
|
||||
listConnectionsApi.mockResolvedValue([])
|
||||
listLinksApi.mockResolvedValue([])
|
||||
|
||||
let ok: boolean | undefined
|
||||
await act(async () => { ok = await result.current.removeConnection(9) })
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(deleteConnectionApi).toHaveBeenCalledWith(TRIP, 9)
|
||||
expect(result.current.connectionFor('nextcloud')).toBeNull()
|
||||
expect(result.current.links).toEqual([])
|
||||
expect(result.current.busy).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-HOOK-032: a refused removal keeps everything and says why', async () => {
|
||||
deleteConnectionApi.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
|
||||
const { result } = await mountLoaded()
|
||||
|
||||
let ok: boolean | undefined
|
||||
await act(async () => { ok = await result.current.removeConnection(9) })
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(result.current.error).toBe('forbidden')
|
||||
expect(result.current.connectionFor('nextcloud')).not.toBeNull()
|
||||
expect(result.current.busy).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('needsReauth', () => {
|
||||
it('FE-DOCSYNC-HOOK-030: reads a refused credential off either the state or the error code', () => {
|
||||
expect(needsReauth({ lastSyncState: 'needs_reauth', lastSyncError: null })).toBe(true)
|
||||
|
||||
@@ -264,6 +264,27 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
|
||||
}
|
||||
}, [tripId, load])
|
||||
|
||||
/**
|
||||
* Forget a stored connection, and with it every binding of this trip that
|
||||
* runs through it. The documents stay on both sides; what goes is the
|
||||
* address and the login TREK kept for the store, which is the one thing a
|
||||
* person changing accounts or folders had no way to take back.
|
||||
*/
|
||||
const removeConnection = useCallback(async (connectionId: number) => {
|
||||
setBusy('connection')
|
||||
setError(null)
|
||||
try {
|
||||
await docsyncApi.deleteConnection(tripId, connectionId)
|
||||
await load()
|
||||
return true
|
||||
} catch (e: unknown) {
|
||||
setError(readError(e))
|
||||
return false
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}, [tripId, load])
|
||||
|
||||
/**
|
||||
* Probe without saving. Always resolves to a verdict object: the route
|
||||
* answers 200 even for an unreachable instance, because a form showing a typo
|
||||
@@ -427,7 +448,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
|
||||
connections, links, itemCounts,
|
||||
loading, busy, error,
|
||||
connectionFor, load,
|
||||
saveConnection, testConnection,
|
||||
saveConnection, testConnection, removeConnection,
|
||||
loadScopes, createScope,
|
||||
createLink, updateLink, removeLink, syncNow, resolveConflict,
|
||||
}
|
||||
|
||||
@@ -102,16 +102,20 @@ export default function HelpPanel(): React.ReactElement | null {
|
||||
<HelpNav context={context} currentId={contextId} activeGuideId={!searching && guide ? guide.id : null} />
|
||||
|
||||
<div className="relative flex flex-col min-h-0 bg-surface" role="main">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeHelp}
|
||||
aria-label={t('help.center.close')}
|
||||
className="absolute top-4 end-4 z-10 w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
{/* The close button sits in a row of its own above the scrolling content rather
|
||||
than floating over it, so no card ever runs up against it. */}
|
||||
<div className="flex shrink-0 justify-end px-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeHelp}
|
||||
aria-label={t('help.center.close')}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div ref={scrollRef} className="trek-help-scroll flex-1 min-h-0 overflow-y-auto overscroll-contain">
|
||||
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 py-8">
|
||||
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 pt-4 pb-8">
|
||||
{searching ? (
|
||||
<HelpSearchResults key="search" />
|
||||
) : guide ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-007
|
||||
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-008
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
@@ -119,3 +119,28 @@ describe('DatePicker week start (#2029)', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('DatePicker inside a clipping dialog (#2858)', () => {
|
||||
it('FE-COMP-JOURNEYDATEPICKER-008: the calendar floats over the page, stays inside the viewport and closes on a click away', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<DatePicker value="2026-03-15" onChange={vi.fn()} />
|
||||
</div>,
|
||||
)
|
||||
const trigger = screen.getByText(/Mar 15, 2026/).closest('button') as HTMLButtonElement
|
||||
// A trigger at the right edge, where the end date of a range sits.
|
||||
trigger.getBoundingClientRect = () => ({ top: 100, bottom: 136, left: window.innerWidth - 120, right: window.innerWidth - 8, width: 112, height: 36, x: 0, y: 0, toJSON: () => ({}) })
|
||||
await user.click(trigger)
|
||||
|
||||
const day31 = screen.getByRole('button', { name: '31' })
|
||||
// Rendered outside the dialog that would clip it.
|
||||
expect(container.contains(day31)).toBe(false)
|
||||
const panel = day31.closest('.fixed') as HTMLElement
|
||||
expect(panel.style.top).toBe('140px')
|
||||
expect(parseFloat(panel.style.left)).toBe(window.innerWidth - 280 - 8)
|
||||
|
||||
await user.click(document.querySelector('[role="presentation"]') as HTMLElement)
|
||||
expect(screen.queryByRole('button', { name: '31' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useWeekStartDay } from '../../hooks/useWeekStartDay'
|
||||
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
|
||||
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
|
||||
|
||||
/**
|
||||
* The header steps up a level on each click, days → months → years, and a
|
||||
@@ -14,6 +16,9 @@ import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
|
||||
*/
|
||||
type CalendarView = 'days' | 'months' | 'years'
|
||||
const YEAR_PAGE_SIZE = 12
|
||||
/** The calendar's width; `w-[280px]` below. */
|
||||
const PANEL_WIDTH = 280
|
||||
const VIEWPORT_PAD = 8
|
||||
|
||||
export function DatePicker({ value, onChange, tripDates }: {
|
||||
value: string
|
||||
@@ -23,6 +28,13 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
const { t, locale } = useTranslation()
|
||||
const weekStart = useWeekStartDay()
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
// The calendar used to hang absolutely under its trigger, inside dialogs that clip
|
||||
// their content: the photo picker cut off the last week and, for the end date in
|
||||
// the right-hand column, the last weekday (#2858). It now floats over the page,
|
||||
// pinned to the trigger, flipped above it when below is too short and kept inside
|
||||
// the viewport sideways.
|
||||
const anchored = useAnchoredPosition(triggerRef, open, { estimatedHeight: 340, matchWidth: false })
|
||||
const [view, setView] = useState<CalendarView>('days')
|
||||
const [yearPageStart, setYearPageStart] = useState(0)
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
@@ -107,6 +119,7 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={toggleOpen}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-start flex items-center justify-between"
|
||||
@@ -122,12 +135,20 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
{open && anchored && createPortal(
|
||||
<>
|
||||
{/* Click-away catcher — no semantics of its own; the trigger button
|
||||
above closes the popover again from the keyboard. */}
|
||||
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full start-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
|
||||
<div role="presentation" className="fixed inset-0 z-[var(--z-toast)]" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="fixed z-[var(--z-toast)] overflow-y-auto bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]"
|
||||
style={{
|
||||
top: anchored.top,
|
||||
bottom: anchored.bottom,
|
||||
left: Math.max(VIEWPORT_PAD, Math.min(anchored.left, window.innerWidth - PANEL_WIDTH - VIEWPORT_PAD)),
|
||||
maxHeight: anchored.maxHeight,
|
||||
}}
|
||||
>
|
||||
{/* Header: arrows step the current view, the label climbs to the next one up */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
|
||||
@@ -200,7 +221,7 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
<span className="absolute bottom-1 inset-x-0 mx-auto w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
@@ -209,7 +230,8 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -375,11 +375,12 @@ describe('ProviderPicker', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Search' }))
|
||||
expect(ranges).toHaveLength(0)
|
||||
|
||||
// The calendar opens in a portal, so its days are looked up on the page, not under the trigger.
|
||||
const [fromPicker, toPicker] = Array.from(baseElement.querySelectorAll('.flex-1 > .relative')) as HTMLElement[]
|
||||
await user.click(within(fromPicker).getByRole('button'))
|
||||
await user.click(within(fromPicker).getByRole('button', { name: '3' }))
|
||||
await user.click(screen.getByRole('button', { name: '3' }))
|
||||
await user.click(within(toPicker).getByRole('button'))
|
||||
await user.click(within(toPicker).getByRole('button', { name: '9' }))
|
||||
await user.click(screen.getByRole('button', { name: '9' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Search' }))
|
||||
|
||||
await waitFor(() => expect(ranges).toHaveLength(1))
|
||||
|
||||
@@ -50,16 +50,10 @@ import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
|
||||
|
||||
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
|
||||
// HTML photo markers *and* cluster them natively, so we feed the place points
|
||||
// into a clustered GeoJSON source. The cluster bubbles render as GL circles +
|
||||
// a count label; the individual rich HTML markers are then only drawn for the
|
||||
// points the source reports as currently unclustered. Grouping is always on,
|
||||
// matching the Leaflet map's MarkerClusterGroup.
|
||||
const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
|
||||
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
|
||||
import {
|
||||
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
|
||||
addPlaceClusterLayers, drawClusterBubbles, expandCluster,
|
||||
} from './placeClusterLayers'
|
||||
|
||||
type PlaceWithCoords = Place & { lat: number; lng: number }
|
||||
|
||||
@@ -112,72 +106,6 @@ interface Props extends MapViewProps {
|
||||
gl: any
|
||||
}
|
||||
|
||||
/**
|
||||
* How eagerly place markers merge into a cluster.
|
||||
*
|
||||
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
|
||||
* kept as plain numbers rather than shared constants so the GL bundle does not have to
|
||||
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
|
||||
* own, and the stops that share a coordinate are folded together at the pin level
|
||||
* instead — see the reconcile below.
|
||||
*/
|
||||
const CLUSTER_RADIUS = 20
|
||||
const CLUSTER_MAX_ZOOM = 8
|
||||
|
||||
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
|
||||
* change and the initial build cannot drift apart. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function addPlaceClusterLayers(map: any): void {
|
||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
clusterRadius: CLUSTER_RADIUS,
|
||||
clusterMaxZoom: CLUSTER_MAX_ZOOM,
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': '#111827',
|
||||
'circle-opacity': 0.97,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||
'circle-stroke-width': 2.5,
|
||||
'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||
type: 'symbol',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(17,24,39,0.35)',
|
||||
'text-halo-width': 1,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
// A stop that interrupts the drive gets its own small disc, decided before the photo
|
||||
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
|
||||
@@ -612,6 +540,8 @@ export function MapViewGL({
|
||||
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
||||
/** The count bubbles, one per cluster the source currently reports. */
|
||||
const clusterPinsRef = useRef<PlacePin[]>([])
|
||||
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
|
||||
const pinLayerRef = useRef<HTMLDivElement | null>(null)
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
@@ -642,6 +572,7 @@ export function MapViewGL({
|
||||
// in the same frame the canvas draws, or the ones left out swim against the ones in.
|
||||
const repositionPins = useCallback(() => {
|
||||
markersRef.current.forEach(pin => pin.reposition())
|
||||
clusterPinsRef.current.forEach(pin => pin.reposition())
|
||||
poiMarkersRef.current.forEach(pin => pin.reposition())
|
||||
pluginMarkersRef.current.forEach(pin => pin.reposition())
|
||||
routeViaMarkersRef.current.forEach(pin => pin.reposition())
|
||||
@@ -1304,21 +1235,7 @@ export function MapViewGL({
|
||||
? map.queryRenderedFeatures(e.point, { layers: [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID] })
|
||||
: []
|
||||
const feature = features?.[0]
|
||||
const clusterId = feature?.properties?.cluster_id
|
||||
const coordinates = feature?.geometry?.coordinates
|
||||
if (clusterId == null || !Array.isArray(coordinates)) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as any
|
||||
const easeToZoom = (nextZoom: number) => {
|
||||
try { map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 }) } catch { /* noop */ }
|
||||
}
|
||||
try {
|
||||
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err: Error | null, nextZoom: number) => {
|
||||
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom)
|
||||
})
|
||||
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom)
|
||||
else if (maybeZoom && typeof maybeZoom.then === 'function') maybeZoom.then(easeToZoom).catch(() => {})
|
||||
} catch { /* noop */ }
|
||||
expandCluster(map, feature?.properties?.cluster_id, feature?.geometry?.coordinates)
|
||||
}
|
||||
const setClusterCursor = () => {
|
||||
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
|
||||
@@ -1704,8 +1621,17 @@ export function MapViewGL({
|
||||
source.setData(buildPlaceClusterData(places) as any)
|
||||
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
|
||||
let raf: number | null = null
|
||||
const dropClusterPins = () => {
|
||||
clusterPinsRef.current.forEach(pin => pin.remove())
|
||||
clusterPinsRef.current = []
|
||||
}
|
||||
const reconcileClusterPins = () => {
|
||||
dropClusterPins()
|
||||
clusterPinsRef.current = drawClusterBubbles(map, (el, lng, lat) => attachPin(map, gl, pinLayerRef.current, el, lng, lat))
|
||||
}
|
||||
const runReconcile = () => {
|
||||
raf = null
|
||||
reconcileClusterPins()
|
||||
const features = map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['!', ['has', 'point_count']] }) || []
|
||||
const seen = new Set<number>()
|
||||
const visiblePlaces: PlaceWithCoords[] = []
|
||||
@@ -1752,6 +1678,7 @@ export function MapViewGL({
|
||||
|
||||
return () => {
|
||||
if (raf !== null) cancelAnimationFrame(raf)
|
||||
dropClusterPins()
|
||||
map.off('moveend', scheduleReconcile)
|
||||
map.off('zoomend', scheduleReconcile)
|
||||
map.off('idle', scheduleReconcile)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import L from 'leaflet'
|
||||
import { STACK_RADIUS_PX } from './coincidentPlaces'
|
||||
import { clusterBubbleSize } from './placeClusterLayers'
|
||||
|
||||
/** Up to here the map is an overview, where stops merge to keep the world readable. */
|
||||
export const CLUSTER_UNTIL_ZOOM = 9
|
||||
@@ -66,7 +67,7 @@ export interface ClusterLike {
|
||||
/** A count bubble sized by its count, styled by `.marker-cluster-custom` in index.css. */
|
||||
export function createClusterIcon(cluster: ClusterLike) {
|
||||
const count = cluster.getChildCount()
|
||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||
const size = clusterBubbleSize(count)
|
||||
return L.divIcon({
|
||||
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
|
||||
className: 'marker-cluster-wrapper',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
type ClusterMap,
|
||||
PLACE_CLUSTER_SOURCE_ID,
|
||||
addPlaceClusterLayers,
|
||||
clusterBubbleSize,
|
||||
createClusterBubble,
|
||||
drawClusterBubbles,
|
||||
} from './placeClusterLayers';
|
||||
|
||||
function fakeMap(features: unknown[], expansionZoom: unknown = 11) {
|
||||
const source = { getClusterExpansionZoom: vi.fn(() => expansionZoom) };
|
||||
const map = {
|
||||
addSource: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
getSource: vi.fn((id: string) => (id === PLACE_CLUSTER_SOURCE_ID ? source : undefined)),
|
||||
easeTo: vi.fn(),
|
||||
querySourceFeatures: vi.fn((_id: string, query: { filter: unknown[] }) =>
|
||||
query.filter[0] === 'has' ? features : []
|
||||
),
|
||||
};
|
||||
return { map: map as unknown as ClusterMap & typeof map, source };
|
||||
}
|
||||
|
||||
describe('cluster bubbles', () => {
|
||||
it('FE-COMP-PLACECLUSTER-001: a bubble grows in three steps with its count', () => {
|
||||
expect([1, 9, 10, 49, 50, 500].map(clusterBubbleSize)).toEqual([36, 36, 42, 42, 48, 48]);
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACECLUSTER-002: the GL bubble is the Leaflet markup inside a bare wrapper the pin can move', () => {
|
||||
const wrapper = createClusterBubble(38);
|
||||
expect(wrapper.className).toBe('');
|
||||
const bubble = wrapper.firstElementChild as HTMLElement;
|
||||
expect(bubble.className).toBe('marker-cluster-custom');
|
||||
expect(bubble.style.width).toBe('42px');
|
||||
expect(bubble.style.height).toBe('42px');
|
||||
expect(bubble.textContent).toBe('38');
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACECLUSTER-003: the canvas circle and count stay as an invisible hit area', () => {
|
||||
const { map } = fakeMap([]);
|
||||
addPlaceClusterLayers(map);
|
||||
expect(map.addSource).toHaveBeenCalledWith(PLACE_CLUSTER_SOURCE_ID, expect.objectContaining({ cluster: true }));
|
||||
const layers = map.addLayer.mock.calls.map(([layer]) => layer as { id: string; paint: Record<string, unknown> });
|
||||
expect(layers.find((l) => l.id === 'trip-place-clusters-circle')?.paint['circle-opacity']).toBe(0);
|
||||
expect(layers.find((l) => l.id === 'trip-place-clusters-count')?.paint['text-opacity']).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACECLUSTER-004: one bubble per cluster, placed at its centre, that zooms in on a click', () => {
|
||||
const cluster = { properties: { cluster_id: 5, point_count: 12 }, geometry: { coordinates: [139.7, 35.6] } };
|
||||
const { map, source } = fakeMap([
|
||||
cluster,
|
||||
cluster, // the same cluster crossing a tile edge
|
||||
{ properties: { cluster_id: 6 }, geometry: { coordinates: [1, 1] } }, // no count
|
||||
{ properties: { cluster_id: 7, point_count: 3 } }, // no geometry
|
||||
]);
|
||||
const placed: { el: HTMLElement; lng: number; lat: number }[] = [];
|
||||
const pins = drawClusterBubbles(map, (el, lng, lat) => {
|
||||
placed.push({ el, lng, lat });
|
||||
return lng;
|
||||
});
|
||||
|
||||
expect(pins).toEqual([139.7]);
|
||||
expect(placed[0].el.textContent).toBe('12');
|
||||
expect(placed[0].lat).toBe(35.6);
|
||||
|
||||
const onMapClick = vi.fn();
|
||||
document.body.appendChild(placed[0].el);
|
||||
document.body.addEventListener('click', onMapClick);
|
||||
placed[0].el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.removeEventListener('click', onMapClick);
|
||||
placed[0].el.remove();
|
||||
|
||||
// The click is the bubble's: the map underneath never sees it.
|
||||
expect(onMapClick).not.toHaveBeenCalled();
|
||||
expect(source.getClusterExpansionZoom).toHaveBeenCalledWith(5, expect.any(Function));
|
||||
expect(map.easeTo).toHaveBeenCalledWith({ center: [139.7, 35.6], zoom: 11, duration: 350 });
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACECLUSTER-005: a source that reports nothing draws nothing', () => {
|
||||
const { map } = fakeMap([]);
|
||||
map.querySourceFeatures.mockReturnValue(null as never);
|
||||
expect(drawClusterBubbles(map, () => 1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,3 +5,169 @@
|
||||
*/
|
||||
export const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle';
|
||||
export const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count';
|
||||
|
||||
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
|
||||
// HTML photo markers *and* cluster them natively, so we feed the place points
|
||||
// into a clustered GeoJSON source. The source decides what is clustered; the
|
||||
// bubbles are drawn as DOM (`drawClusterBubbles`) and the individual rich HTML
|
||||
// markers only for the points the source reports as currently unclustered.
|
||||
// Grouping is always on, matching the Leaflet map's MarkerClusterGroup.
|
||||
export const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters';
|
||||
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit';
|
||||
|
||||
/**
|
||||
* How eagerly place markers merge into a cluster.
|
||||
*
|
||||
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
|
||||
* kept as plain numbers rather than shared constants so the GL bundle does not have to
|
||||
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
|
||||
* own, and the stops that share a coordinate are folded together at the pin level
|
||||
* instead — see the reconcile in MapViewGL.
|
||||
*/
|
||||
const CLUSTER_RADIUS = 20;
|
||||
const CLUSTER_MAX_ZOOM = 8;
|
||||
|
||||
/** The part of either SDK's map the cluster code calls. */
|
||||
export interface ClusterMap {
|
||||
addSource: (id: string, source: object) => void;
|
||||
addLayer: (layer: object) => void;
|
||||
getSource: (id: string) => unknown;
|
||||
easeTo: (options: { center: unknown[]; zoom: number; duration: number }) => void;
|
||||
querySourceFeatures: (id: string, options: { filter: unknown[] }) => ClusterFeature[] | null | undefined;
|
||||
}
|
||||
|
||||
interface ClusterFeature {
|
||||
properties?: { cluster_id?: unknown; point_count?: unknown } | null;
|
||||
geometry?: { coordinates?: unknown };
|
||||
}
|
||||
|
||||
interface ClusterSource {
|
||||
getClusterExpansionZoom?: (id: unknown, done: (err: Error | null, zoom: number) => void) => unknown;
|
||||
}
|
||||
|
||||
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
|
||||
* change and the initial build cannot drift apart. */
|
||||
export function addPlaceClusterLayers(map: Pick<ClusterMap, 'addSource' | 'addLayer'>): void {
|
||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
clusterRadius: CLUSTER_RADIUS,
|
||||
clusterMaxZoom: CLUSTER_MAX_ZOOM,
|
||||
});
|
||||
// Invisible: the bubble the user sees is DOM (createClusterBubble), these two
|
||||
// layers stay as the hit area the track and route layers check a click against.
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||
type: 'symbol',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: { 'text-opacity': 0 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Eases the map in to the zoom a cluster falls apart at. Both SDKs answer that
|
||||
* question, one through a callback and one through a promise. */
|
||||
export function expandCluster(
|
||||
map: Pick<ClusterMap, 'getSource' | 'easeTo'>,
|
||||
clusterId: unknown,
|
||||
coordinates: unknown
|
||||
): void {
|
||||
if (clusterId == null || !Array.isArray(coordinates)) return;
|
||||
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as ClusterSource | undefined;
|
||||
const easeToZoom = (nextZoom: number) => {
|
||||
try {
|
||||
map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 });
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
try {
|
||||
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err, nextZoom) => {
|
||||
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom);
|
||||
});
|
||||
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom);
|
||||
else if (maybeZoom instanceof Promise) maybeZoom.then(easeToZoom).catch(() => {});
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts one count bubble on the map per cluster the source currently reports, through
|
||||
* `place` (the caller's pin factory), and returns what `place` handed back. A cluster
|
||||
* crossing a tile edge comes back once per tile, so bubbles are keyed by cluster id.
|
||||
*/
|
||||
export function drawClusterBubbles<T>(map: ClusterMap, place: (el: HTMLElement, lng: number, lat: number) => T): T[] {
|
||||
const pins: T[] = [];
|
||||
const seen = new Set<unknown>();
|
||||
for (const feature of map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['has', 'point_count'] }) ?? []) {
|
||||
const clusterId = feature?.properties?.cluster_id;
|
||||
const count = feature?.properties?.point_count;
|
||||
const coordinates = feature?.geometry?.coordinates;
|
||||
if (typeof count !== 'number' || !Array.isArray(coordinates) || seen.has(clusterId)) continue;
|
||||
seen.add(clusterId);
|
||||
const el = createClusterBubble(count);
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
expandCluster(map, clusterId, coordinates);
|
||||
});
|
||||
pins.push(place(el, coordinates[0], coordinates[1]));
|
||||
}
|
||||
return pins;
|
||||
}
|
||||
|
||||
/** A count bubble's diameter in pixels: both renderers size their bubbles by it. */
|
||||
export function clusterBubbleSize(count: number): number {
|
||||
return count < 10 ? 36 : count < 50 ? 42 : 48;
|
||||
}
|
||||
|
||||
/**
|
||||
* A count bubble as DOM, styled by `.marker-cluster-custom` in index.css.
|
||||
*
|
||||
* The GL map draws its bubbles with this element rather than as canvas text, so they
|
||||
* read in the app's own font and get the same hover the Leaflet map has. A canvas
|
||||
* symbol layer can only use the glyphs the map style ships and knows no `:hover`.
|
||||
* The bubble sits in a bare wrapper because the pin positions its element through
|
||||
* `transform`, which would otherwise override the hover's scale.
|
||||
*/
|
||||
export function createClusterBubble(count: number): HTMLDivElement {
|
||||
const size = clusterBubbleSize(count);
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'marker-cluster-custom';
|
||||
bubble.style.width = `${size}px`;
|
||||
bubble.style.height = `${size}px`;
|
||||
const label = document.createElement('span');
|
||||
label.textContent = String(count);
|
||||
bubble.appendChild(label);
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.appendChild(bubble);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -485,6 +485,17 @@ describe('KategorieGruppe — inline add item', () => {
|
||||
expect(onAddItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CAT-028a: Enter on the first item typed into the placeholder row opens the add field (#2825)', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/1', () => HttpResponse.json({ item: buildPackingItem({ id: 1, name: 'Tent', category: 'Gear' }) })))
|
||||
setup({ items: [buildPackingItem({ id: 1, name: '...', category: 'Gear' })] })
|
||||
|
||||
fireEvent.click(screen.getByText('...'))
|
||||
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('...'), { key: 'Enter' })
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Item name...')).toHaveFocus())
|
||||
})
|
||||
|
||||
it('FE-W5CAT-028: the confirm button adds the item, and stays inert while empty', () => {
|
||||
const onAddItem = vi.fn(async () => {})
|
||||
const { container } = setup({ onAddItem })
|
||||
|
||||
@@ -86,6 +86,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
const [showAddItem, setShowAddItem] = useState(false)
|
||||
const [newItemName, setNewItemName] = useState('')
|
||||
const addItemRef = useRef<HTMLInputElement>(null)
|
||||
const openAddItem = () => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const assigneeDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { togglePackingItem } = useTripStore()
|
||||
@@ -292,7 +293,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
const contributions = contribFor(item.id)
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
<ArtikelZeile item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} divider={index > 0}
|
||||
<ArtikelZeile item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} onPlaceholderFilled={openAddItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} divider={index > 0}
|
||||
tripMembers={tripMembers} currentUserId={currentUserId} onSetSharing={onSetSharing} onClone={onClone} onJoin={onJoin} onLeave={onLeave}
|
||||
drag={canEdit && reorderable ? {
|
||||
isDragging: dragId === item.id,
|
||||
@@ -343,7 +344,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
// A row of its own on a faint tint, so it closes the card instead
|
||||
// of trailing off: a dashed box in the checkbox column (past the drag
|
||||
// grip's width) and the label where an item's name sits.
|
||||
<button type="button" onClick={() => { setShowAddItem(true); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
<button type="button" onClick={openAddItem}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 9, width: 'calc(100% - 8px)', margin: '6px 4px 0', paddingBlock: 8, paddingInlineEnd: 10, paddingInlineStart: 28, borderRadius: 10, border: 'none', background: 'var(--bg-secondary)', cursor: 'pointer', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, color: 'var(--text-faint)', fontFamily: 'inherit', textAlign: 'start' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
|
||||
@@ -257,6 +257,23 @@ describe('ArtikelZeile — renaming', () => {
|
||||
await waitFor(() => expect(screen.getByText('...')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-W5ROW-008b: Enter on a filled placeholder row hands the typing on, a blur does not (#2825)', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/1', () => HttpResponse.json({ item: buildPackingItem({ id: 1, name: 'Tent' }) })))
|
||||
const onPlaceholderFilled = vi.fn()
|
||||
setup({ item: buildPackingItem({ id: 1, name: '...', category: 'Gear' }), onPlaceholderFilled })
|
||||
|
||||
fireEvent.click(screen.getByText('...'))
|
||||
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
|
||||
fireEvent.blur(screen.getByPlaceholderText('...'))
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('...')).toBeNull())
|
||||
expect(onPlaceholderFilled).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByText('...'))
|
||||
fireEvent.change(screen.getByPlaceholderText('...'), { target: { value: 'Tent' } })
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('...'), { key: 'Enter' })
|
||||
await waitFor(() => expect(onPlaceholderFilled).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-W5ROW-009: a failing rename surfaces a save error', async () => {
|
||||
server.use(http.put('/api/trips/1/packing/1', () => new HttpResponse(null, { status: 500 })))
|
||||
setup()
|
||||
|
||||
@@ -25,6 +25,8 @@ interface ArtikelZeileProps {
|
||||
categories: string[]
|
||||
onCategoryChange: () => void
|
||||
onDelete?: (item: PackingItem) => Promise<void>
|
||||
/** Enter on the filled '...' row, so the category's add field can take over the typing (#2825). */
|
||||
onPlaceholderFilled?: () => void
|
||||
bagTrackingEnabled?: boolean
|
||||
bags?: PackingBag[]
|
||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||
@@ -49,7 +51,7 @@ interface ArtikelZeileProps {
|
||||
}
|
||||
}
|
||||
|
||||
export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, divider = false, drag }: ArtikelZeileProps) {
|
||||
export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCategoryChange, onDelete, onPlaceholderFilled, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true, tripMembers = [], currentUserId, onSetSharing, onClone, onJoin, onLeave, divider = false, drag }: ArtikelZeileProps) {
|
||||
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
||||
@@ -80,9 +82,12 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
|
||||
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
|
||||
|
||||
const handleSaveName = async () => {
|
||||
const handleSaveName = async (viaEnter = false) => {
|
||||
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return }
|
||||
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
|
||||
try {
|
||||
await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false)
|
||||
if (viaEnter && isPlaceholder) onPlaceholderFilled?.()
|
||||
}
|
||||
catch { toast.error(t('packing.toast.saveError')) }
|
||||
}
|
||||
|
||||
@@ -188,8 +193,8 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
type="text" value={editName} autoFocus
|
||||
placeholder={isPlaceholder ? '...' : undefined}
|
||||
onChange={e => setEditName(e.target.value)}
|
||||
onBlur={handleSaveName}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
|
||||
onBlur={() => void handleSaveName()}
|
||||
onKeyDown={e => { if (e.key === 'Enter') void handleSaveName(true); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }}
|
||||
style={{ flex: 1, minWidth: 0, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
|
||||
/>
|
||||
) : canEdit ? (
|
||||
|
||||
@@ -138,6 +138,34 @@ describe('TransitSearchPanel', () => {
|
||||
expect(payload.metadata.transit.legs[1]).toMatchObject({ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben' })
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-003b: routes Google answered name Google Maps and are saved as Google’s (#2850)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onAdd = vi.fn().mockResolvedValue({})
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: 'google' })
|
||||
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
|
||||
await pickFromAndTo(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
await user.click(await screen.findByText(/08:30 – 09:00/))
|
||||
|
||||
// Google's terms want the words as written, so the browser must not translate them.
|
||||
expect(screen.getByText('Google Maps')).toHaveAttribute('translate', 'no')
|
||||
expect(screen.queryByRole('link', { name: 'Transitous' })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
|
||||
await waitFor(() => expect(onAdd).toHaveBeenCalled())
|
||||
expect(onAdd.mock.calls[0][0].metadata.transit.provider).toBe('google')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-003c: routes Transitous answered credit Transitous with its sources link', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: 'transitous' })
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
await pickFromAndTo(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
expect(await screen.findByRole('link', { name: 'Transitous' })).toHaveAttribute('href', 'https://transitous.org/sources/')
|
||||
expect(screen.queryByText('Google Maps')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
@@ -604,7 +604,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
notes: null,
|
||||
metadata: {
|
||||
transit: {
|
||||
provider: 'transitous',
|
||||
provider: provider ?? 'transitous',
|
||||
duration: it.duration,
|
||||
transfers: it.transfers,
|
||||
walk_seconds: it.walkSeconds,
|
||||
@@ -760,7 +760,10 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
))}
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', textAlign: 'center', marginTop: 2 }}>
|
||||
{t('transit.attribution')}{' '}
|
||||
<a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>
|
||||
{/* Named after the backend that answered; Google's terms want "Google Maps" as written, never translated. */}
|
||||
{provider === 'google'
|
||||
? <span translate="no">Google Maps</span>
|
||||
: <a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -803,6 +803,34 @@ describe('useRoadtripRoutes', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-052: a join this device routed before is not paced like a request (#2794)', async () => {
|
||||
// A join kept its request slot whatever answered it, so a month of connected days
|
||||
// waited half a minute on every open with every road already on the device. The
|
||||
// clock is held still, so every answer reads as one from the cache; a join that
|
||||
// still waited its turn would wait 1.1 s of real time, past what waitFor gives it.
|
||||
const clock = vi.spyOn(performance, 'now').mockImplementation(() => 10_000)
|
||||
try {
|
||||
const MUNICH: [number, number] = [48.1374, 11.5755]
|
||||
const INNSBRUCK: [number, number] = [47.2692, 11.4041]
|
||||
const BOZEN: [number, number] = [46.4983, 11.3548]
|
||||
setting({ roadtrip_connect_days: true })
|
||||
const daysList = [day(1, 1), day(2, 2), day(3, 3)]
|
||||
const assignments = {
|
||||
...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]),
|
||||
...map(2, [{ id: 3, at: BERLIN, time: '09:00' }, { id: 4, at: MUNICH }]),
|
||||
...map(3, [{ id: 5, at: INNSBRUCK, time: '09:00' }, { id: 6, at: BOZEN }]),
|
||||
} as AssignmentsMap
|
||||
renderHook(() => useRoadtripRoutes(7, daysList, assignments))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1)
|
||||
expect(askedFor(MUNICH, INNSBRUCK)).toHaveLength(1)
|
||||
})
|
||||
} finally {
|
||||
clock.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-ROUTES-051: a join OSRM answered in the engine’s place is drawn, flagged, and asked for again on request', async () => {
|
||||
// Filed like any answer, it stood in for the weighed road until the page was loaded
|
||||
// again, and a picker on it read the stand-in line as the road already taken.
|
||||
|
||||
@@ -692,11 +692,19 @@ export function useRoadtripRoutes(
|
||||
* for both, so a choice being checked cannot fire into the gap a seam is keeping.
|
||||
*/
|
||||
const lastSpacedRequestAt = useRef(0)
|
||||
/** Waits until the gap since the last such request is kept, then claims the next slot. */
|
||||
const spacedRequest = useCallback(async (signal: AbortSignal): Promise<void> => {
|
||||
/**
|
||||
* Waits until the gap since the last such request is kept, then claims the next slot.
|
||||
* Hands back what gives the slot up again, for a request the cache answered.
|
||||
*/
|
||||
const spacedRequest = useCallback(async (signal: AbortSignal): Promise<() => void> => {
|
||||
const since = performance.now() - lastSpacedRequestAt.current
|
||||
if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, signal)
|
||||
if (!signal.aborted) lastSpacedRequestAt.current = performance.now()
|
||||
const before = lastSpacedRequestAt.current
|
||||
const claimed = performance.now()
|
||||
if (!signal.aborted) lastSpacedRequestAt.current = claimed
|
||||
return () => {
|
||||
if (lastSpacedRequestAt.current === claimed) lastSpacedRequestAt.current = before
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -727,7 +735,7 @@ export function useRoadtripRoutes(
|
||||
// shared routing hosts answered the tail with 429, and a refused seam
|
||||
// writes no state, so nothing changed to make the effect try again: the
|
||||
// map drew the trip in pieces and the totals came back short.
|
||||
await spacedRequest(controller.signal)
|
||||
const release = await spacedRequest(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
try {
|
||||
// The via points on this seam, threaded in the same way the day runs thread
|
||||
@@ -735,6 +743,7 @@ export function useRoadtripRoutes(
|
||||
// shape: it is asked for on its own, so the points the traveller dropped on it
|
||||
// never reached the router and dragging one did visibly nothing.
|
||||
const shaping = viasOnLeg(seam.from, seam.to, viasByDay[seam.from.ownerDayId] ?? [])
|
||||
const startedAt = performance.now()
|
||||
const r = await calculateRouteWithLegs(
|
||||
[
|
||||
{ lat: seam.from.lat, lng: seam.from.lng },
|
||||
@@ -743,6 +752,10 @@ export function useRoadtripRoutes(
|
||||
],
|
||||
{ signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: seam.dayId, avoid },
|
||||
)
|
||||
// A seam this device routed before came back from its cache and went nowhere, so
|
||||
// its slot goes back before the store below re-runs this effect. Holding it made a
|
||||
// trip of connected days wait a second per join on every open (#2794).
|
||||
if (performance.now() - startedAt <= CACHE_HIT_MS) release()
|
||||
if (controller.signal.aborted) return
|
||||
// One leg per waypoint PAIR, so a shaped seam comes back in pieces and the rail
|
||||
// wants the whole drive: merged here, exactly as a day run folds its via legs
|
||||
|
||||
@@ -47,6 +47,14 @@ describe('aiConnectModel', () => {
|
||||
expect(connectErrorKey(new Error('boom'))).toBe('assistant.error.generic');
|
||||
});
|
||||
|
||||
it('FE-AI-CONNECT-MODEL-002b: tells an own server that answered without a model list from one that is down (#2808)', () => {
|
||||
const noList = { response: { data: { code: 'provider_unavailable', reason: 'no_model_list' } } };
|
||||
expect(connectErrorKey(noList)).toBe('assistant.connect.noModelList');
|
||||
expect(connectErrorKey({ response: { data: { code: 'provider_unavailable', reason: 'other' } } })).toBe(
|
||||
'assistant.error.providerDown'
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-AI-CONNECT-MODEL-003: offers the way back into a broken connection, none for a working one', () => {
|
||||
expect(repairModeOf(VIEW)).toBeNull();
|
||||
expect(repairModeOf({ ...VIEW, status: 'error' })).toBe('key');
|
||||
|
||||
@@ -33,9 +33,20 @@ const CONNECT_ERRORS: Record<string, { messageKey: string }> = {
|
||||
offline: { messageKey: 'assistant.error.offline' },
|
||||
};
|
||||
|
||||
/** The line a failed connect shows. Takes `{provider}` where the line names one. */
|
||||
/** What a refused connect says for the `reason` it carries next to its code, ahead of the code's line. */
|
||||
const CONNECT_REASONS: Record<string, { messageKey: string }> = {
|
||||
no_model_list: { messageKey: 'assistant.connect.noModelList' },
|
||||
};
|
||||
|
||||
/**
|
||||
* The line a failed connect shows. Takes `{provider}` where the line names one.
|
||||
* An own server that answered without a model list is up, so it gets its own
|
||||
* line about the address instead of "not responding" (#2808).
|
||||
*/
|
||||
export function connectErrorKey(err: unknown): string {
|
||||
return CONNECT_ERRORS[refusalCode(err) ?? '']?.messageKey ?? 'assistant.error.generic';
|
||||
const reason = (err as { response?: { data?: { reason?: unknown } } } | null)?.response?.data?.reason;
|
||||
const byReason = typeof reason === 'string' ? CONNECT_REASONS[reason] : undefined;
|
||||
return byReason?.messageKey ?? CONNECT_ERRORS[refusalCode(err) ?? '']?.messageKey ?? 'assistant.error.generic';
|
||||
}
|
||||
|
||||
/** How each connection state reads on its pill. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-003
|
||||
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-004
|
||||
// The GPX preview as ACT-01 uses it: preset to the tour's type, a city walk
|
||||
// imported as one, and when a track was recorded shown where it carried times.
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
@@ -47,6 +47,13 @@ describe('TourGpxPreviewDialog (ACT-01)', () => {
|
||||
expect(recorded[0]).toHaveTextContent('50 min');
|
||||
});
|
||||
|
||||
it('FE-GPX-PREVIEW-004: a track taken over from before Tours reads as one, not as a duplicate (#2810)', () => {
|
||||
render(<TourGpxPreviewDialog gpxImport={gpxImport([track({ adopts_track: true })])} />);
|
||||
expect(screen.getByTestId('gpx-preview-track')).not.toHaveAttribute('data-duplicate');
|
||||
expect(screen.getByText(/tours\.import\.previewAdopts|becomes a tour/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/tours\.import\.previewDuplicate|Already in the trip/)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-GPX-PREVIEW-002: imports as the preset type, a paddle tour with its boat and water', () => {
|
||||
const current = gpxImport([track()]);
|
||||
render(
|
||||
|
||||
@@ -79,6 +79,7 @@ function PreviewTrackRow({
|
||||
{track.name || name}
|
||||
</span>
|
||||
{track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>}
|
||||
{track.adopts_track && <SoftPill>{t('tours.import.previewAdopts')}</SoftPill>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5 ps-8" style={fs(11)}>
|
||||
<span className={BADGE}>{formatDistance(track.distance_km, unit)}</span>
|
||||
|
||||
@@ -212,7 +212,7 @@ function TourTimeFields({ planner, locked }: TourFieldProps) {
|
||||
id="tour-planner-planned-duration"
|
||||
label={t('tours.planner.plannedTotalDuration')}
|
||||
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes}
|
||||
invalid={planner.plannedDurationInvalid}
|
||||
invalid={planner.plannedDurationInvalid || planner.plannedTotalInvalid}
|
||||
errorId="tour-planner-planned-duration-error"
|
||||
locked={locked}
|
||||
onCommit={planner.setPlannedDurationMinutes}
|
||||
@@ -220,6 +220,10 @@ function TourTimeFields({ planner, locked }: TourFieldProps) {
|
||||
{planner.plannedDurationInvalid && (
|
||||
<FieldError id="tour-planner-planned-duration-error">{t('tours.planner.timeValueInvalid')}</FieldError>
|
||||
)}
|
||||
{/* #2869: over a day a stage Save waits, and says why rather than only greying out. */}
|
||||
{!planner.plannedDurationInvalid && planner.plannedTotalInvalid && (
|
||||
<FieldError id="tour-planner-planned-duration-error">{t('tours.planner.plannedTotalOverDay')}</FieldError>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -337,7 +341,11 @@ export function TourDetailsSection({
|
||||
}: TourFieldProps & { onDifficultyChange: (value: Difficulty) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(readDetailsOpen);
|
||||
const forcedOpen = planner.websiteInvalid || planner.plannedDurationInvalid || planner.breakAdditionalInvalid;
|
||||
const forcedOpen =
|
||||
planner.websiteInvalid ||
|
||||
planner.plannedDurationInvalid ||
|
||||
planner.breakAdditionalInvalid ||
|
||||
planner.plannedTotalInvalid;
|
||||
const expanded = open || forcedOpen;
|
||||
const toggle = () => {
|
||||
const next = !expanded;
|
||||
|
||||
@@ -526,6 +526,18 @@ describe('TourPlannerRail', () => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole value from 0 to 1,440 minutes.')
|
||||
})
|
||||
|
||||
it('says why Save waits on a tour over a day a stage, and opens the details to say it (#2869)', () => {
|
||||
window.localStorage.clear()
|
||||
render(<TourPlannerRail planner={planner({
|
||||
mode: { type: 'new-draft' }, hasUnsavedChanges: true, canSave: false,
|
||||
walkingDurationMinutes: 1500, plannedTotalMinutes: 1500, plannedTotalInvalid: true,
|
||||
})} />)
|
||||
expect(screen.getByLabelText(/Planned total duration/)).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Walking time and breaks add up to more than 24 hours a day. Make it a multi-day tour with “End stage here” at a stop, or enter a shorter planned total.',
|
||||
)
|
||||
})
|
||||
|
||||
it('RS-02: Info controls are keyboard focusable and explain themselves only through the shared tooltip', async () => {
|
||||
render(<TourPlannerRail planner={planner({
|
||||
mode: { type: 'new-draft' }, hasUnsavedChanges: true, walkingDurationMinutes: 80,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-024
|
||||
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-027
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../../../tests/helpers/render'
|
||||
@@ -137,6 +137,12 @@ beforeEach(() => {
|
||||
vi.spyOn(docsyncApi, 'createLink').mockResolvedValue({})
|
||||
vi.spyOn(docsyncApi, 'items').mockResolvedValue([])
|
||||
vi.spyOn(docsyncApi, 'resolve').mockResolvedValue({ success: true })
|
||||
vi.spyOn(docsyncApi, 'deleteConnection').mockImplementation(async (_tripId, id) => {
|
||||
// The bindings go with their connection, as ON DELETE CASCADE has it.
|
||||
connections = connections.filter(c => c.id !== id)
|
||||
links = links.filter(l => l.connectionId !== id)
|
||||
return { success: true }
|
||||
})
|
||||
})
|
||||
|
||||
/** The status route with a number of conflicts on it, holdings as usual. */
|
||||
@@ -528,3 +534,45 @@ describe('MDocSyncSheet: what a member may do', () => {
|
||||
expect(screen.queryByRole('button', { name: /Papra/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MDocSyncSheet: the stored connection', () => {
|
||||
const removeButtons = () => screen.getAllByRole('button', { name: 'Remove connection' })
|
||||
|
||||
it('FE-DOCSYNC-MOBILE-025: a binding opens its connection at any time, and removing it asks first', async () => {
|
||||
renderSheet()
|
||||
await openDetail()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit connection' }))
|
||||
expect(await screen.findByPlaceholderText('https://paperless.example')).toHaveValue('https://paperless.example')
|
||||
|
||||
fireEvent.click(removeButtons()[0])
|
||||
expect(docsyncApi.deleteConnection).not.toHaveBeenCalled()
|
||||
expect(await screen.findByText(/TREK forgets the address and login/)).toBeInTheDocument()
|
||||
// The confirmation sheet opens over the form, so its button is the last one of that name.
|
||||
const named = removeButtons()
|
||||
fireEvent.click(named[named.length - 1])
|
||||
|
||||
await waitFor(() => expect(docsyncApi.deleteConnection).toHaveBeenCalledWith(TRIP_ID, 5))
|
||||
// Back at the list, with Paperless offered again from scratch.
|
||||
expect(await screen.findByText('Connect a provider')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-MOBILE-026: a stored connection without a binding can be edited from the folder picker', async () => {
|
||||
renderSheet()
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Nextcloud/ }))
|
||||
await screen.findByText('Photos')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit connection' }))
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Remove connection' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-MOBILE-027: a member sees no way to the connection', async () => {
|
||||
renderSheet({ canManage: false })
|
||||
await openDetail()
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Edit connection' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
ArrowLeft, ArrowRight, Check, ChevronLeft, ChevronRight, FolderPlus, FolderSync, KeyRound, Link2Off,
|
||||
Loader2, Plus, RefreshCw,
|
||||
Loader2, Plus, RefreshCw, Trash2,
|
||||
} from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import MIconBtn from '../../../components/MIconBtn'
|
||||
@@ -149,6 +149,7 @@ export default function MDocSyncSheet({
|
||||
provider={pending}
|
||||
sync={sync}
|
||||
onDone={connected}
|
||||
onRemoved={() => { setReconnectFor(null); setPending(null); setView('list') }}
|
||||
/>
|
||||
) : view === 'scope' && pending && sync.connectionFor(pending.id) ? (
|
||||
<ScopeView
|
||||
@@ -156,6 +157,7 @@ export default function MDocSyncSheet({
|
||||
suggestedName={slugFor(tripTitle, tripId)}
|
||||
sync={sync}
|
||||
onBound={() => setView('list')}
|
||||
onEditConnection={() => setView('connect')}
|
||||
/>
|
||||
) : view === 'detail' && link ? (
|
||||
<DetailView
|
||||
@@ -417,6 +419,19 @@ function DetailView({
|
||||
</button>
|
||||
</SettingRow>
|
||||
|
||||
{onReconnect && (
|
||||
<SettingRow label={t('docsync.connection.title')} hint={t('docsync.connection.hint')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReconnect}
|
||||
className="flex items-center gap-1.5 rounded-full bg-[color:var(--m-ic)] px-3 py-[6px] font-geist text-caption font-bold text-m-ink"
|
||||
>
|
||||
<KeyRound size={12} strokeWidth={2.2} />
|
||||
{t('docsync.connection.edit')}
|
||||
</button>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUnlink}
|
||||
@@ -443,14 +458,18 @@ function ConnectView({
|
||||
provider,
|
||||
sync,
|
||||
onDone,
|
||||
onRemoved,
|
||||
}: {
|
||||
provider: DocSyncProvider
|
||||
sync: ReturnType<typeof useDocSync>
|
||||
onDone: () => void
|
||||
onRemoved: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const form = useConnectForm(provider, sync.connectionFor(provider.id), sync)
|
||||
const existing = sync.connectionFor(provider.id)
|
||||
const form = useConnectForm(provider, existing, sync)
|
||||
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
@@ -534,6 +553,32 @@ function ConnectView({
|
||||
{sync.busy === 'save' ? <Loader2 size={15} className="mx-auto animate-spin" /> : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{existing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
disabled={sync.busy === 'connection'}
|
||||
className="flex h-11 w-full items-center justify-center gap-2 rounded-2xl border border-[color:var(--m-rowbr)] text-body font-bold text-[color:var(--m-st-danger)] disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={2.2} />
|
||||
{t('docsync.connection.remove')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<MConfirmSheet
|
||||
open={confirmRemove}
|
||||
onClose={() => setConfirmRemove(false)}
|
||||
title={t('docsync.connection.remove')}
|
||||
message={t('docsync.connection.confirmRemove')}
|
||||
confirmLabel={t('docsync.connection.remove')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
onConfirm={() => {
|
||||
setConfirmRemove(false)
|
||||
if (existing) void sync.removeConnection(existing.id).then(ok => { if (ok) onRemoved() })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -544,11 +589,14 @@ function ScopeView({
|
||||
suggestedName,
|
||||
sync,
|
||||
onBound,
|
||||
onEditConnection,
|
||||
}: {
|
||||
connectionId: number
|
||||
suggestedName: string
|
||||
sync: ReturnType<typeof useDocSync>
|
||||
onBound: () => void
|
||||
/** Back to the stored connection, to change the login or remove it before choosing a folder. */
|
||||
onEditConnection: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { scopes, error, name, setName, working, bind, createAndBind } = useScopePicker({
|
||||
@@ -562,6 +610,15 @@ function ScopeView({
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<p className="font-geist text-[0.71875rem] leading-snug text-m-muted">{t('docsync.scope.intro')}</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditConnection}
|
||||
className="flex h-9 items-center justify-center gap-1.5 self-start rounded-full bg-[color:var(--m-ic)] px-3 font-geist text-caption font-bold text-m-ink"
|
||||
>
|
||||
<KeyRound size={12} strokeWidth={2.2} />
|
||||
{t('docsync.connection.edit')}
|
||||
</button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={name}
|
||||
|
||||
@@ -13,9 +13,9 @@ import { studioLayerActions, type StudioLayerActions } from './studioLayers'
|
||||
* The book being edited.
|
||||
*
|
||||
* History is snapshot-based rather than patch-based, and deliberately so: a
|
||||
* document is a few hundred kilobytes of plain JSON at most (the schema caps it
|
||||
* at 150 spreads of 60 elements), and a snapshot stack that cannot possibly
|
||||
* disagree with the document beats a patch stack that can. It is capped so a
|
||||
* snapshot shares every page it did not change with the one before it, so even
|
||||
* a book of hundreds of pages costs one page per step, and a snapshot stack
|
||||
* that cannot possibly disagree with the document beats a patch stack that can. It is capped so a
|
||||
* long session does not grow without limit.
|
||||
*
|
||||
* Granularity is the part that matters for how it *feels*: a drag is one undo
|
||||
|
||||
@@ -268,9 +268,11 @@ describe('the entry spreads', () => {
|
||||
})
|
||||
|
||||
describe('the document as a whole', () => {
|
||||
it('stays within the schema cap on spreads', () => {
|
||||
it('lays out a long journey whole, its last page and back cover after the entries', () => {
|
||||
const many = Array.from({ length: 200 }, (_, i) => entry({ id: i + 1 }))
|
||||
expect(build({ entries: many }).spreads.length).toBeLessThanOrEqual(150)
|
||||
const doc = build({ entries: many })
|
||||
expect(doc.spreads.length).toBeGreaterThan(150)
|
||||
expect(doc.spreads.slice(-2).map(sp => sp.role)).toEqual(['last', 'back'])
|
||||
})
|
||||
|
||||
it('gives every spread and every element an id of its own', () => {
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@trek/root",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@trek/client",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@fontsource/abril-fatface": "^5.3.0",
|
||||
@@ -25005,7 +25005,7 @@
|
||||
},
|
||||
"server": {
|
||||
"name": "@trek/server",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"dependencies": {
|
||||
"@aws-lite/client": "^0.23.7",
|
||||
"@aws-lite/s3": "^0.2.7",
|
||||
@@ -25384,7 +25384,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@trek/shared",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"dependencies": {
|
||||
"isomorphic-dompurify": "^3.23.0",
|
||||
"zod": "^4.6.7"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/server",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
// Build server/assets/airports.json from OurAirports (davidmegginson.github.io/ourairports-data).
|
||||
// License: Public Domain. Keeps large/medium airports with an IATA code; timezone derived from coords via tz-lookup.
|
||||
// License: Public Domain. Keeps large/medium airports with an IATA code, small ones only with scheduled service;
|
||||
// timezone derived from coords via tz-lookup.
|
||||
import fs from 'node:fs';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
@@ -91,7 +92,9 @@ for (let i = 1; i < rows.length; i++) {
|
||||
if (!KEEP.has(r[TYPE])) continue;
|
||||
const iata = r[IATA]?.trim().toUpperCase();
|
||||
if (!iata || iata.length !== 3) continue;
|
||||
if (r[SERVICE] !== 'yes') continue;
|
||||
// The scheduled_service flag lags reality: Little Cayman (LYB) has daily flights and is
|
||||
// marked "no". So it only filters the small fields, where it keeps every airstrip with a code out.
|
||||
if (r[TYPE] === 'small_airport' && r[SERVICE] !== 'yes') continue;
|
||||
const lat = Number(r[LAT]);
|
||||
const lng = Number(r[LNG]);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"@typescript-eslint/no-require-imports": 18,
|
||||
"@typescript-eslint/no-unsafe-function-type": 1,
|
||||
"@typescript-eslint/no-unused-vars": 36,
|
||||
"no-await-in-loop": 563,
|
||||
"no-await-in-loop": 562,
|
||||
"no-console": 235,
|
||||
"no-empty": 6
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"src/nest/budget/budget.mcp.ts": 36,
|
||||
"src/nest/collab/collab.mcp.ts": 20,
|
||||
"src/nest/collections/collections.mcp.ts": 14,
|
||||
"src/nest/day-notes/day-notes.mcp.ts": 10,
|
||||
"src/nest/day-notes/day-notes.mcp.ts": 6,
|
||||
"src/nest/days/days.mcp.ts": 2,
|
||||
"src/nest/doc-sync/doc-sync.mcp.ts": 1,
|
||||
"src/nest/files/files.mcp.ts": 4,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"scripts/db-call-graph.mjs": 1036,
|
||||
"scripts/generate-entities.ts": 2259,
|
||||
"src/db/repositories/Places.repository.ts": 1439,
|
||||
"src/db/repositories/Places.repository.ts": 1438,
|
||||
"src/db/repositories/Reservations.repository.ts": 1604,
|
||||
"src/db/repositories/Trips.repository.ts": 1121,
|
||||
"src/db/repositories/Users.repository.ts": 1445,
|
||||
|
||||
@@ -177,13 +177,15 @@ export async function buildApp(): Promise<INestApplication> {
|
||||
* it is the wrong one for a photo book. A Studio document is sent WHOLE on
|
||||
* every autosave — deliberately, see book-store.schema.ts, because a patch
|
||||
* protocol would need an ordering guarantee and a merge rule per field — and
|
||||
* the contract already caps it at 150 spreads of 90 elements. A real book of
|
||||
* a fortnight's journey is well past a hundred kilobytes before anybody asks
|
||||
* a book has as many pages as its journey needs. A real book of a
|
||||
* fortnight's journey is well past a hundred kilobytes before anybody asks
|
||||
* for road geometry, and what the ceiling produces is not an error message
|
||||
* but a save that quietly fails and an editor that says "not saved" without
|
||||
* saying why.
|
||||
*
|
||||
* So the book route is measured against the size a book can actually be.
|
||||
* An auto laid page is two to five kilobytes, so eight megabytes hold a
|
||||
* book of a few thousand pages: the page count has no limit of its own.
|
||||
*
|
||||
* A list file is the same story at a smaller scale. Its contract allows a
|
||||
* megabyte and the import posts it whole, yet a list of a few hundred places
|
||||
|
||||
@@ -375,6 +375,12 @@ const aiProviderStateSchema = z.discriminatedUnion('adapter', [
|
||||
adapter: z.literal('anthropic-messages'),
|
||||
blocks: z.array(z.record(z.string(), z.unknown())),
|
||||
}),
|
||||
// Gemini's thought signature per tool call id, which its Chat Completions endpoint wants back with the call.
|
||||
z.object({
|
||||
adapter: z.literal('openai-chat'),
|
||||
model: z.string(),
|
||||
signatures: z.record(z.string(), z.string()),
|
||||
}),
|
||||
]);
|
||||
export type AiProviderState = z.infer<typeof aiProviderStateSchema>;
|
||||
|
||||
|
||||
@@ -605,16 +605,15 @@ export class PlacesRepository extends TrekRepository<Places> {
|
||||
* it was.
|
||||
*/
|
||||
async listDedupInputs(trip_id: string): Promise<{
|
||||
name: string | null; lat: number | null; lng: number | null;
|
||||
name: string | null; lat: number | null; lng: number | null; route_geometry: string | null;
|
||||
google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null;
|
||||
}[]> {
|
||||
// route_geometry tells a track from a pin: a track to import matches only tracks (#2810).
|
||||
return this.qb('p')
|
||||
.select(['p.name', 'p.lat', 'p.lng', 'p.google_place_id', 'p.google_ftid', 'p.osm_id', 'p.amap_poi_id'])
|
||||
.select(['p.name', 'p.lat', 'p.lng', 'p.route_geometry',
|
||||
'p.google_place_id', 'p.google_ftid', 'p.osm_id', 'p.amap_poi_id'])
|
||||
.where('p.trip_id = ?', [trip_id])
|
||||
.execute<{
|
||||
name: string | null; lat: number | null; lng: number | null;
|
||||
google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null;
|
||||
}[]>('all', false);
|
||||
.execute<Awaited<ReturnType<PlacesRepository['listDedupInputs']>>>('all', false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { safeFetch, safeFetchLlm } from '../../utils/ssrfGuard';
|
||||
import type { AiChatClient } from './ai-chat-client.interface';
|
||||
import { AI_BASE_URLS } from './ai-connections.constants';
|
||||
import { AI_BASE_URLS, ownServerChatBase } from './ai-connections.constants';
|
||||
import type { ResolvedAiConnection } from './ai-connections.types';
|
||||
import { ChatgptAuthService } from './chatgpt-auth.service';
|
||||
import { AnthropicMessagesClient } from './providers/anthropic-messages.client';
|
||||
@@ -67,7 +67,7 @@ export class AiChatClientService {
|
||||
return new OpenAiChatClient(
|
||||
kind,
|
||||
model,
|
||||
(connection.baseUrl ?? '').replace(/(?<!\/)\/+$/, ''),
|
||||
ownServerChatBase(connection.baseUrl),
|
||||
credential.apiKey,
|
||||
adminServerFetch,
|
||||
);
|
||||
|
||||
@@ -22,6 +22,26 @@ export function aiHostOf(kind: AiConnectionKind, baseUrl: string | null): string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an own server's Chat Completions live. An address with a path, such as
|
||||
* `https://api.groq.com/openai/v1` or `http://ollama.lan:11434/v1`, is used as
|
||||
* entered. A bare server address gets `/v1`: Ollama, LM Studio, vLLM,
|
||||
* llama.cpp and LiteLLM all answer there, while Ollama answers
|
||||
* `/chat/completions` at its root with a 404. Connecting already found the
|
||||
* model list of such an address under the root, so the chat failed where the
|
||||
* connection had worked.
|
||||
*/
|
||||
export function ownServerChatBase(baseUrl: string | null): string {
|
||||
const entered = (baseUrl ?? '').trim().replace(/(?<!\/)\/+$/, '');
|
||||
try {
|
||||
const url = new URL(entered);
|
||||
if (url.pathname === '/' && !url.search && !url.hash) return `${entered}/v1`;
|
||||
} catch {
|
||||
// An address that does not parse fails on the request, as it did before.
|
||||
}
|
||||
return entered;
|
||||
}
|
||||
|
||||
/** Whether OpenRouter's OAuth can send the browser back here: it only returns to an https address. */
|
||||
export function openrouterOAuthAvailable(): boolean {
|
||||
return getAppUrl().startsWith('https://');
|
||||
@@ -91,7 +111,17 @@ const PROVIDER_MESSAGES: Partial<Record<AssistantErrorCode, string>> = {
|
||||
provider_unavailable: 'The provider is not responding',
|
||||
};
|
||||
|
||||
/** A provider's failure as the refusal a REST route answers with. */
|
||||
/**
|
||||
* A provider's failure as the refusal a REST route answers with. An own server
|
||||
* that answered without a model list says so in `reason`, so the form can name
|
||||
* the address instead of calling a server that is up unresponsive (#2808).
|
||||
*/
|
||||
export function providerRefusal(err: AiProviderError): DomainError {
|
||||
if (err.providerCode === 'no_model_list') {
|
||||
return new DomainError(REFUSAL_STATUS[err.code], 'The server answered, but sent no model list', {
|
||||
code: err.code,
|
||||
details: { code: err.code, reason: 'no_model_list' },
|
||||
});
|
||||
}
|
||||
return aiRefusal(err.code, PROVIDER_MESSAGES[err.code] ?? 'The provider refused the request');
|
||||
}
|
||||
|
||||
@@ -238,7 +238,15 @@ export class ModelCatalogService {
|
||||
const { models } = await this.llmLocal.listModels(baseUrl ?? undefined, apiKey ?? undefined);
|
||||
return models.map((m) => ({ id: m.name, label: m.name, supportsTools: true }));
|
||||
} catch (err) {
|
||||
if (err instanceof DomainError) throw new AiProviderError('provider_unavailable', err.getStatus(), true);
|
||||
// The server's own answer decides what the admin reads: a refused key, a
|
||||
// server that answered without a model list, or one that is not there.
|
||||
// All three read as "not responding" before, which sent people looking
|
||||
// at a server that was up (#2808).
|
||||
if (err instanceof DomainError) {
|
||||
if (err.code === 'llm_key_refused') throw new AiProviderError('invalid_key', err.getStatus(), false);
|
||||
const reason = err.code === 'llm_no_model_list' ? 'no_model_list' : undefined;
|
||||
throw new AiProviderError('provider_unavailable', err.getStatus(), true, undefined, reason);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,16 +26,29 @@ import type { AiConnectionKind } from '@trek/shared';
|
||||
/** The end of an OpenAI-compatible stream. */
|
||||
const DONE = '[DONE]';
|
||||
|
||||
/** The host of Gemini's OpenAI-compatible endpoint, which an admin may also enter as their own server. */
|
||||
const GEMINI_HOST = 'generativelanguage.googleapis.com';
|
||||
|
||||
function isGeminiHost(baseUrl: string): boolean {
|
||||
try {
|
||||
return new URL(baseUrl).hostname === GEMINI_HOST;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider-neutral history as Chat Completions messages, the instructions
|
||||
* and the trip card first. A tool message takes no image, so the images of a
|
||||
* step's tool results follow its last result as one user message.
|
||||
* step's tool results follow its last result as one user message. `model` is
|
||||
* the one the request goes to: a thought signature goes back only to the model
|
||||
* that signed it, since another one cannot read it.
|
||||
*/
|
||||
export function toChatMessages(request: AiChatRequest): Record<string, unknown>[] {
|
||||
export function toChatMessages(request: AiChatRequest, model?: string): Record<string, unknown>[] {
|
||||
const system = [request.system, request.context].filter(Boolean).join('\n\n');
|
||||
const out: Record<string, unknown>[] = [{ role: 'system', content: system }];
|
||||
request.messages.forEach((message, index) => {
|
||||
out.push(toChatMessage(message));
|
||||
out.push(toChatMessage(message, model));
|
||||
const withImages = toolImagesEndingAt(request.messages, index);
|
||||
if (withImages.length === 0) return;
|
||||
const content = withImages.flatMap((tool) => [
|
||||
@@ -51,12 +64,14 @@ function imagePart(image: AiImage): Record<string, unknown> {
|
||||
return { type: 'image_url', image_url: { url: imageDataUrl(image), detail: image.detail ?? 'high' } };
|
||||
}
|
||||
|
||||
function toChatMessage(message: AiChatMessage): Record<string, unknown> {
|
||||
function toChatMessage(message: AiChatMessage, model: string | undefined): Record<string, unknown> {
|
||||
if (message.role === 'user') {
|
||||
if (!message.images?.length) return { role: 'user', content: message.text };
|
||||
return { role: 'user', content: [{ type: 'text', text: message.text }, ...message.images.map(imagePart)] };
|
||||
}
|
||||
if (message.role === 'tool') return { role: 'tool', tool_call_id: message.toolCallId, content: message.output };
|
||||
const state = message.providerState;
|
||||
const signatures = state?.adapter === 'openai-chat' && state.model === model ? state.signatures : {};
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: message.text || null,
|
||||
@@ -66,19 +81,32 @@ function toChatMessage(message: AiChatMessage): Record<string, unknown> {
|
||||
id: call.id,
|
||||
type: 'function',
|
||||
function: { name: call.name, arguments: JSON.stringify(call.args) },
|
||||
...(signatures[call.id] ? { extra_content: { google: { thought_signature: signatures[call.id] } } } : {}),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Gemini signs a tool call on its first piece; the call has to go back with it or the next request is refused. */
|
||||
function thoughtSignatureOf(piece: Record<string, unknown>): string {
|
||||
return asText(asRecord(asRecord(piece.extra_content).google).thought_signature);
|
||||
}
|
||||
|
||||
/** The id a call goes on with; a provider that names none gets one from the call's position. */
|
||||
function callId(index: number, id: string): string {
|
||||
return id || `call_${index}`;
|
||||
}
|
||||
|
||||
const FINISH_REASONS: Record<string, AiFinishReason> = { tool_calls: 'tool-calls', length: 'length' };
|
||||
|
||||
/**
|
||||
* The OpenAI-compatible Chat Completions API, streamed: OpenAI itself, Gemini
|
||||
* (its `/v1beta/openai` endpoint), OpenRouter and an admin's own server. The
|
||||
* kind decides only what a request may carry: Gemini gets its narrower tool
|
||||
* schemas, OpenAI the prompt cache key, which the others do not know.
|
||||
* schemas, also when an admin enters its endpoint as their own server, and
|
||||
* OpenAI the prompt cache key, which the others do not know. Gemini's thought
|
||||
* signatures come back as the turn's provider state.
|
||||
*/
|
||||
export class OpenAiChatClient implements AiChatClient {
|
||||
/** OpenRouter searches the web for any model through its web plugin; the others have no search here. */
|
||||
@@ -86,20 +114,25 @@ export class OpenAiChatClient implements AiChatClient {
|
||||
return this.kind === 'openrouter';
|
||||
}
|
||||
|
||||
/** The kind whose tool schemas the endpoint takes. */
|
||||
private readonly schemaKind: AiConnectionKind;
|
||||
|
||||
constructor(
|
||||
readonly kind: AiConnectionKind,
|
||||
readonly model: string,
|
||||
private readonly baseUrl: string,
|
||||
private readonly apiKey: string | null,
|
||||
private readonly fetcher: AiFetch,
|
||||
) {}
|
||||
) {
|
||||
this.schemaKind = kind === 'custom' && isGeminiHost(baseUrl) ? 'gemini' : kind;
|
||||
}
|
||||
|
||||
async *stream(request: AiChatRequest, signal: AbortSignal): AsyncGenerator<AiChatEvent> {
|
||||
const body = {
|
||||
model: this.model,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
messages: toChatMessages(request),
|
||||
messages: toChatMessages(request, this.model),
|
||||
...(request.tools.length > 0
|
||||
? {
|
||||
tools: request.tools.map((tool) => ({
|
||||
@@ -107,7 +140,7 @@ export class OpenAiChatClient implements AiChatClient {
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: toProviderSchema(tool.parameters, this.kind),
|
||||
parameters: toProviderSchema(tool.parameters, this.schemaKind),
|
||||
},
|
||||
})),
|
||||
tool_choice: 'auto',
|
||||
@@ -153,10 +186,22 @@ export class OpenAiChatClient implements AiChatClient {
|
||||
cachedInputTokens: turn.cachedInputTokens,
|
||||
outputTokens: turn.outputTokens,
|
||||
};
|
||||
const signatures = turn.signatures();
|
||||
if (Object.keys(signatures).length > 0) {
|
||||
yield { type: 'provider-state', state: { adapter: 'openai-chat', model: this.model, signatures } };
|
||||
}
|
||||
yield { type: 'finish', reason: turn.finishReason() };
|
||||
}
|
||||
}
|
||||
|
||||
/** A tool call while it streams in: its id, name and arguments so far, and Gemini's signature. */
|
||||
interface PendingCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one streamed Chat Completions turn has said so far. A tool call
|
||||
* arrives in pieces keyed by its index (its id and name in the first, the
|
||||
@@ -168,7 +213,7 @@ class ChatTurn {
|
||||
outputTokens = 0;
|
||||
reason: string | null = null;
|
||||
done = false;
|
||||
private readonly pending = new Map<number, { id: string; name: string; args: string }>();
|
||||
private readonly pending = new Map<number, PendingCall>();
|
||||
private readonly cited = new Set<string>();
|
||||
private fresh: AiSource[] = [];
|
||||
|
||||
@@ -199,10 +244,11 @@ class ChatTurn {
|
||||
const piece = asRecord(raw);
|
||||
const index = typeof piece.index === 'number' ? piece.index : position;
|
||||
const fn = asRecord(piece.function);
|
||||
const call = this.pending.get(index) ?? { id: '', name: '', args: '' };
|
||||
const call = this.pending.get(index) ?? { id: '', name: '', args: '', signature: '' };
|
||||
call.id ||= asText(piece.id);
|
||||
call.name ||= asText(fn.name);
|
||||
call.args += asText(fn.arguments);
|
||||
call.signature ||= thoughtSignatureOf(piece);
|
||||
this.pending.set(index, call);
|
||||
});
|
||||
return asText(delta.content);
|
||||
@@ -216,10 +262,24 @@ class ChatTurn {
|
||||
}
|
||||
|
||||
calls(): AiToolCall[] {
|
||||
return [...this.pending.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.filter(([, call]) => call.name)
|
||||
.map(([index, call]) => ({ id: call.id || `call_${index}`, name: call.name, args: parseToolArgs(call.args) }));
|
||||
return this.named().map(([index, call]) => ({
|
||||
id: callId(index, call.id),
|
||||
name: call.name,
|
||||
args: parseToolArgs(call.args),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The thought signature of each signed call, by the id the call goes back with. */
|
||||
signatures(): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
this.named()
|
||||
.filter(([, call]) => call.signature)
|
||||
.map(([index, call]) => [callId(index, call.id), call.signature]),
|
||||
);
|
||||
}
|
||||
|
||||
private named(): [number, PendingCall][] {
|
||||
return [...this.pending.entries()].sort(([a], [b]) => a - b).filter(([, call]) => call.name);
|
||||
}
|
||||
|
||||
finishReason(): AiFinishReason {
|
||||
|
||||
@@ -59,11 +59,21 @@ export async function refusalOf(kind: AiConnectionKind, res: Response): Promise<
|
||||
// refusal the provider's own code already explains (a plan that does not cover it) keeps that code.
|
||||
const image = namesImageInput(res.status, body) && providerFailure(kind, res.status, named).code === 'generic';
|
||||
const code = image ? IMAGE_INPUT_REFUSED : named;
|
||||
// Status and code only: an error body can quote the request, and the request holds the trip.
|
||||
logWarn(`[ai] ${kind} refused the request: HTTP ${res.status}${code ? ` (${code})` : ''}`);
|
||||
// Status, code and the refused field's path only: an error message can quote the request, and the
|
||||
// request holds the trip. The path alone says which part of a replayed conversation the provider refused.
|
||||
const param = refusedParamOf(body);
|
||||
logWarn(
|
||||
`[ai] ${kind} refused the request: HTTP ${res.status}${code ? ` (${code})` : ''}${param ? ` at ${param}` : ''}`,
|
||||
);
|
||||
return providerFailure(kind, res.status, code);
|
||||
}
|
||||
|
||||
/** The field an error body names as refused (OpenAI's `param`, such as `input[12]`), when it is a bare path. */
|
||||
function refusedParamOf(body: unknown): string | undefined {
|
||||
const param = asRecord(asRecord(body).error).param;
|
||||
return typeof param === 'string' && /^[\w.[\]-]{1,80}$/.test(param) ? param : undefined;
|
||||
}
|
||||
|
||||
/** One SSE data payload as a JSON object; null for anything else (a `[DONE]`, a keepalive). */
|
||||
export function parseEventData(data: string): Record<string, unknown> | null {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fitToSize } from './assistant-output';
|
||||
import {
|
||||
ADD_FOUND_PLACE_TOOL,
|
||||
APPROVAL_TTL_MS,
|
||||
ASSISTANT_MODEL_CALLED_SERVER_TOOLS,
|
||||
ASSISTANT_SERVER_TOOLS,
|
||||
IMPORT_BOOKING_TOOL,
|
||||
IMPORT_TOUR_FILE_TOOL,
|
||||
@@ -190,13 +191,6 @@ function earlierCut(text: string, earlier: boolean): string {
|
||||
: text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model's own calls on attached files: told after the step that made
|
||||
* them, so the history ends on the user's side when the user's decision on
|
||||
* them goes on.
|
||||
*/
|
||||
const ATTACHED_FILE_TOOLS: ReadonlySet<string> = new Set([READ_ATTACHED_FILE_TOOL, KEEP_ATTACHED_FILE_TOOL]);
|
||||
|
||||
/** What became of keeping an attached file in Files, as its stored part says. */
|
||||
function keptFileLine(part: ToolCallPart): string {
|
||||
const file = quoted(part.args.name);
|
||||
@@ -234,30 +228,31 @@ function attachedReadLine(
|
||||
}
|
||||
|
||||
/**
|
||||
* What the model is told of its reads of attached files in one step: the
|
||||
* text of each file the user let it read, the first time the history holds
|
||||
* it (`told` keeps track), and what became of a read the user did not allow.
|
||||
* `texts` are the shared files' texts, by attachment id; one no longer there
|
||||
* was swept with its file.
|
||||
* The answer a replayed call gets: what became of an attached file (the text
|
||||
* of a file the user let it read, the first time the history holds it, `told`
|
||||
* keeping track; `texts` are the shared files' texts by attachment id), "not
|
||||
* applied" for a held change that never ran, the stored result otherwise.
|
||||
*/
|
||||
function attachedReadNote(
|
||||
calls: readonly ToolCallPart[],
|
||||
function replayedOutput(
|
||||
part: ToolCallPart,
|
||||
texts: ReadonlyMap<string, string>,
|
||||
told: Set<string>,
|
||||
earlier: boolean,
|
||||
): string {
|
||||
return calls
|
||||
.map((part) =>
|
||||
part.toolName === KEEP_ATTACHED_FILE_TOOL ? keptFileLine(part) : attachedReadLine(part, texts, told, earlier),
|
||||
)
|
||||
.join('\n\n');
|
||||
if (part.toolName === KEEP_ATTACHED_FILE_TOOL) return keptFileLine(part);
|
||||
if (part.toolName === READ_ATTACHED_FILE_TOOL) return attachedReadLine(part, texts, told, earlier);
|
||||
if (part.result === undefined) return NOT_APPLIED;
|
||||
return outputText(earlier ? fitToSize(part.result, EARLIER_TOOL_OUTPUT_CHARS) : part.result);
|
||||
}
|
||||
|
||||
/**
|
||||
* The thread as the provider reads it, oldest first, from its first user
|
||||
* message on. A step's tool calls are followed by their answers; a held
|
||||
* change that was never applied answers that it was not, since every call
|
||||
* must be answered. `providerIds` maps a held call's own id to the id the
|
||||
* must be answered. That includes the server's tools the model called itself
|
||||
* (a trip note, an attached file): the providers that replay their own record
|
||||
* of a step refuse it with a call left unanswered, and every later message of
|
||||
* the thread with it. `providerIds` maps a held call's own id to the id the
|
||||
* provider gave it.
|
||||
*/
|
||||
export function toChatHistory(
|
||||
@@ -282,42 +277,31 @@ export function toChatHistory(
|
||||
(part): part is Extract<AssistantMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName !== ASSISTANT_WEB_SEARCH_STEP,
|
||||
);
|
||||
const reads = allCalls.filter((part) => ATTACHED_FILE_TOOLS.has(part.toolName));
|
||||
const serverCalls = allCalls.filter(
|
||||
(part) => ASSISTANT_SERVER_TOOLS.has(part.toolName) && !ATTACHED_FILE_TOOLS.has(part.toolName),
|
||||
);
|
||||
const serverStep = (part: ToolCallPart) =>
|
||||
ASSISTANT_SERVER_TOOLS.has(part.toolName) && !ASSISTANT_MODEL_CALLED_SERVER_TOOLS.has(part.toolName);
|
||||
// Told as the user's side of the conversation: the history then ends on it
|
||||
// when a decision on these bookings starts the next answer.
|
||||
if (serverCalls.length > 0) history.push({ role: 'user', text: serverStepsNote(serverCalls) });
|
||||
const calls = allCalls.filter((part) => !ASSISTANT_SERVER_TOOLS.has(part.toolName));
|
||||
const note = serverStepsNote(allCalls.filter(serverStep));
|
||||
if (note) history.push({ role: 'user', text: note });
|
||||
const calls = allCalls.filter((part) => !serverStep(part));
|
||||
const toolCalls: AiToolCall[] = calls.map((part) => ({
|
||||
id: providerIds.get(part.toolCallId) ?? part.toolCallId,
|
||||
name: part.toolName,
|
||||
args: part.args,
|
||||
}));
|
||||
const text = textOf(parts);
|
||||
// Told after the step that read the files, so the history ends on the user's side when a consent goes on.
|
||||
const readNote = reads.length > 0 ? attachedReadNote(reads, attachedTexts, told, earlier) : '';
|
||||
if (!text && toolCalls.length === 0) {
|
||||
if (readNote) history.push({ role: 'user', text: readNote });
|
||||
return;
|
||||
}
|
||||
if (!text && toolCalls.length === 0) return;
|
||||
const providerState = decodeJson(ASSISTANT_PROVIDER_STATE, row.provider_state, `message ${row.id}`);
|
||||
history.push({ role: 'assistant', text, toolCalls, providerState });
|
||||
calls.forEach((part, index) => {
|
||||
const output =
|
||||
part.result === undefined
|
||||
? NOT_APPLIED
|
||||
: outputText(earlier ? fitToSize(part.result, EARLIER_TOOL_OUTPUT_CHARS) : part.result);
|
||||
history.push({
|
||||
role: 'tool',
|
||||
toolCallId: toolCalls[index].id,
|
||||
name: part.toolName,
|
||||
output,
|
||||
output: replayedOutput(part, attachedTexts, told, earlier),
|
||||
...(part.isError ? { isError: true } : {}),
|
||||
});
|
||||
});
|
||||
if (readNote) history.push({ role: 'user', text: readNote });
|
||||
});
|
||||
return history;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,18 @@ export const ASSISTANT_SERVER_TOOLS: ReadonlySet<string> = new Set([
|
||||
...Object.keys(ASSISTANT_SERVER_WRITE_TOOLS),
|
||||
]);
|
||||
|
||||
/**
|
||||
* The server's tools the model calls itself. TREK answers them, but they are
|
||||
* calls in the provider's own record of the step, so a replayed step has to
|
||||
* answer them like any other; the rest of the server's tools are steps the
|
||||
* server took, which the provider never saw.
|
||||
*/
|
||||
export const ASSISTANT_MODEL_CALLED_SERVER_TOOLS: ReadonlySet<string> = new Set([
|
||||
REMEMBER_TOOL,
|
||||
READ_ATTACHED_FILE_TOOL,
|
||||
KEEP_ATTACHED_FILE_TOOL,
|
||||
]);
|
||||
|
||||
/** The largest file the assistant reads. */
|
||||
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
|
||||
const dayId = id(args, 'dayId');
|
||||
const rows = new Rows()
|
||||
.add('day', dayLabel(ctx, dayId))
|
||||
.add('time', text(args.time))
|
||||
.add('content', text(args.time))
|
||||
.add('icon', text(args.icon))
|
||||
.add('color', text(args.color));
|
||||
return { subject: text(args.text), rows, dayId, placeIds: [] };
|
||||
@@ -203,8 +203,8 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
|
||||
update_day_note: (ctx, args) => {
|
||||
const note = ctx.card.notes.get(id(args, 'noteId') ?? 0);
|
||||
const rows = new Rows();
|
||||
if (has(args, 'text')) rows.change('text', note?.text ?? null, text(args.text));
|
||||
if (has(args, 'time')) rows.change('time', note?.time ?? null, text(args.time));
|
||||
if (has(args, 'text')) rows.change('title', note?.text ?? null, text(args.text));
|
||||
if (has(args, 'time')) rows.change('content', note?.time ?? null, text(args.time));
|
||||
if (has(args, 'icon')) rows.change('icon', note?.icon ?? null, text(args.icon));
|
||||
if (has(args, 'color')) rows.change('color', note?.color ?? null, text(args.color));
|
||||
return { subject: note?.text ?? text(args.text), rows, dayId: id(args, 'dayId'), placeIds: [] };
|
||||
|
||||
@@ -11,7 +11,13 @@ import {
|
||||
} from '../../nest-mcp';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { DayNotesService } from './day-notes.service';
|
||||
import { idSchema, NOTE_COLORS, type NoteColor } from '@trek/shared';
|
||||
import {
|
||||
dayNoteCreateRequestSchema,
|
||||
dayNoteUpdateRequestSchema,
|
||||
idSchema,
|
||||
NOTE_COLORS,
|
||||
type NoteColor,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -23,6 +29,18 @@ import { z } from 'zod';
|
||||
*/
|
||||
const noteColorSchema = z.enum(NOTE_COLORS);
|
||||
|
||||
/**
|
||||
* The note's two text fields carry their names from the time a note was a line
|
||||
* with a clock time: `text` is the heading and `time` the Markdown body under it.
|
||||
* The REST contract holds the bounds, so a note written in the planner can always
|
||||
* be edited here; the descriptions are what tells a model which field is which.
|
||||
*/
|
||||
const NOTE_TITLE = 'The note title, shown as its heading in the day plan. Keep it short';
|
||||
const NOTE_BODY =
|
||||
'The note body shown under the title: details, links, reminders, Markdown allowed. ' +
|
||||
'Despite the field name this is the content, not a clock time';
|
||||
const noteTitleSchema = dayNoteCreateRequestSchema.shape.text.describe(NOTE_TITLE);
|
||||
|
||||
function parseId(value: string | string[]): number | null {
|
||||
const n = Number(Array.isArray(value) ? value[0] : value);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
@@ -52,12 +70,12 @@ export class DayNotesMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'create_day_note',
|
||||
description: 'Add a note to a specific day in a trip.',
|
||||
description: 'Add a note to a specific day in a trip: a title in `text` and its body in `time`.',
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
dayId: idSchema,
|
||||
text: z.string().min(1).max(500),
|
||||
time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'),
|
||||
text: noteTitleSchema,
|
||||
time: dayNoteCreateRequestSchema.shape.time.describe(NOTE_BODY),
|
||||
icon: z.string().max(64).optional().describe('Emoji icon for the note'),
|
||||
color: noteColorSchema
|
||||
.nullable()
|
||||
@@ -86,7 +104,7 @@ export class DayNotesMcp {
|
||||
tripId: number;
|
||||
dayId: number;
|
||||
text: string;
|
||||
time?: string;
|
||||
time?: string | null;
|
||||
icon?: string;
|
||||
color?: NoteColor | null;
|
||||
sort_order?: number;
|
||||
@@ -104,18 +122,14 @@ export class DayNotesMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'update_day_note',
|
||||
description: 'Edit an existing note on a specific day.',
|
||||
description: 'Edit an existing note on a specific day: its title is `text`, its body `time`.',
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
dayId: idSchema,
|
||||
noteId: idSchema,
|
||||
text: z.string().min(1).max(500).optional(),
|
||||
time: z
|
||||
.string()
|
||||
.max(250)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
|
||||
// The create bound, so an update cannot blank the title the planner requires.
|
||||
text: noteTitleSchema.optional(),
|
||||
time: dayNoteUpdateRequestSchema.shape.time.describe(`${NOTE_BODY}, or null to clear it`),
|
||||
icon: z.string().max(64).optional().describe('Emoji icon for the note'),
|
||||
color: noteColorSchema
|
||||
.nullable()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
bookSpreadPhotoIds,
|
||||
compareCodeUnits,
|
||||
MAX_SPREADS,
|
||||
type BookDesignPlanRow,
|
||||
type BookDesignSkipped,
|
||||
type BookDocument,
|
||||
@@ -176,7 +175,7 @@ export function applyDesignDraft(
|
||||
if (at < 0 || !samePage(spreads[at], base.get(row.spreadId))) return skip(row.spreadId, 'changed_since');
|
||||
spreads[at] = page;
|
||||
} else {
|
||||
if (at >= 0 || spreads.length >= MAX_SPREADS) return skip(row.spreadId, 'changed_since');
|
||||
if (at >= 0) return skip(row.spreadId, 'changed_since');
|
||||
spreads.splice(insertAt(spreads, draft.plan, i, page), 0, page);
|
||||
}
|
||||
applied.push(row.spreadId);
|
||||
|
||||
@@ -178,7 +178,7 @@ export class JourneyBookDesignService {
|
||||
baseVersion: draft.baseVersion,
|
||||
expiresAt: new Date(draft.expiresAt).toISOString(),
|
||||
document: draft.doc,
|
||||
plan: draft.plan.slice(0, BOOK_DESIGN_LIMITS.selection).map(planRowOut),
|
||||
plan: draft.plan.map(planRowOut),
|
||||
selection: draft.selection,
|
||||
};
|
||||
}
|
||||
@@ -376,7 +376,7 @@ function answerOf(draft: BookDesignDraft): BookDesignAnswer {
|
||||
expires_at: new Date(draft.expiresAt).toISOString(),
|
||||
plan: rows,
|
||||
more_rows: draft.plan.length - rows.length,
|
||||
selected: draft.selection.slice(0, BOOK_DESIGN_LIMITS.selection),
|
||||
selected: draft.selection,
|
||||
style: draft.style,
|
||||
photos: {
|
||||
used: draft.usedPhotos,
|
||||
|
||||
@@ -26,6 +26,12 @@ const tagsResponseSchema = z.object({
|
||||
/** The OpenAI-compatible list (`GET /v1/models`) that LM Studio, vLLM and llama.cpp serve. */
|
||||
const openAiModelsSchema = z.object({ data: z.array(z.object({ id: z.string().optional() })) });
|
||||
|
||||
/** One place a server may list its models, and how its answer reads. */
|
||||
interface ModelListSource {
|
||||
url: string;
|
||||
read: (body: unknown) => { name?: string; size?: number }[] | undefined;
|
||||
}
|
||||
|
||||
/** The Bearer header the extraction sends, when a key is configured. */
|
||||
function authHeader(apiKey: string | undefined): Record<string, string> {
|
||||
return apiKey ? { authorization: `Bearer ${apiKey}` } : {};
|
||||
@@ -43,11 +49,23 @@ function upstreamRefusal(status: number, apiKey: string | undefined, fallback: s
|
||||
apiKey
|
||||
? `Local LLM server rejected the API key (${status})`
|
||||
: `Local LLM server requires an API key (${status}). Save the key for this address first.`,
|
||||
{ code: 'llm_key_refused' },
|
||||
);
|
||||
}
|
||||
return new DomainError(502, fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* A URL without its trailing slashes. Scanned off instead of `/\/+$/`-replaced:
|
||||
* that pattern re-walks the slash run from every start position of the
|
||||
* admin-supplied URL.
|
||||
*/
|
||||
function trimTrailingSlashes(url: string): string {
|
||||
let end = url.length;
|
||||
while (end > 0 && url.charCodeAt(end - 1) === 47 /* '/' */) end--;
|
||||
return url.slice(0, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin helpers for managing a local OpenAI-compatible LLM server (Ollama).
|
||||
* Talks to Ollama's *management* API (`/api/tags`, `/api/pull`), which lives at
|
||||
@@ -72,11 +90,7 @@ export class LlmLocalService {
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new HttpException({ error: 'Base URL must be http(s)' }, 400);
|
||||
}
|
||||
// Trailing slashes are scanned off instead of `/\/+$/`-replaced: that pattern
|
||||
// re-walks the slash run from every start position of the admin-supplied URL.
|
||||
let end = raw.length;
|
||||
while (end > 0 && raw.charCodeAt(end - 1) === 47 /* '/' */) end--;
|
||||
return raw.slice(0, end).replace(/\/v1$/, '');
|
||||
return trimTrailingSlashes(raw).replace(/\/v1$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,39 +108,69 @@ export class LlmLocalService {
|
||||
}
|
||||
|
||||
/**
|
||||
* List models already pulled on the local server. A server without Ollama's
|
||||
* `/api/tags` (LM Studio, vLLM, llama.cpp) answers 404 there, so its
|
||||
* OpenAI-compatible `/v1/models` is asked instead.
|
||||
* List the models the server offers. Ollama's `/api/tags` is asked first, then
|
||||
* the OpenAI-compatible list: at the address as entered (the one the chat
|
||||
* calls `/chat/completions` on, `https://api.groq.com/openai/v1` say), then at
|
||||
* `/v1/models` under the root for an address given without `/v1`.
|
||||
*
|
||||
* An answer only counts when it reads as a model list. LM Studio answers an
|
||||
* unknown path with 200 and an error object, and a proxy may answer it with a
|
||||
* login page, so stopping at the first answer that is not a 404 refused
|
||||
* servers whose list was one request further (#2808).
|
||||
*/
|
||||
async listModels(
|
||||
baseUrl: string | undefined,
|
||||
apiKey?: string,
|
||||
): Promise<{ models: { name: string; size: number }[] }> {
|
||||
const root = this.ollamaRoot(baseUrl);
|
||||
let res = await this.getModelList(`${root}/api/tags`, root, apiKey);
|
||||
let openAi = false;
|
||||
if (res.status === 404) {
|
||||
const fallback = await this.getModelList(`${root}/v1/models`, root, apiKey);
|
||||
if (fallback.ok) {
|
||||
discardBody(res);
|
||||
res = fallback;
|
||||
openAi = true;
|
||||
const asEntered = trimTrailingSlashes((baseUrl ?? '').trim()) || root;
|
||||
const lists: ModelListSource[] = [
|
||||
{ url: `${root}/api/tags`, read: (body) => tagsResponseSchema.safeParse(body).data?.models },
|
||||
...[...new Set([`${asEntered}/models`, `${root}/v1/models`])].map((url) => ({
|
||||
url,
|
||||
read: (body: unknown) => openAiModelsSchema.safeParse(body).data?.data.map((m) => ({ name: m.id, size: 0 })),
|
||||
})),
|
||||
];
|
||||
const seen = { firstStatus: 0, refusedStatus: 0 };
|
||||
const listed = await this.firstModelList(lists, 0, root, apiKey, seen);
|
||||
if (listed) return { models: listed.map((m) => ({ name: m.name ?? '', size: m.size ?? 0 })).filter((m) => m.name) };
|
||||
if (seen.refusedStatus) throw upstreamRefusal(seen.refusedStatus, apiKey, '');
|
||||
throw new DomainError(502, `Local LLM server sent no model list (${seen.firstStatus})`, {
|
||||
code: 'llm_no_model_list',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The first of `lists`, from `index` on, that answers with a readable list,
|
||||
* asked one after the other. Only the first request tells whether the server
|
||||
* is there at all; a later one failing is that path, not the server.
|
||||
*/
|
||||
private async firstModelList(
|
||||
lists: ModelListSource[],
|
||||
index: number,
|
||||
root: string,
|
||||
apiKey: string | undefined,
|
||||
seen: { firstStatus: number; refusedStatus: number },
|
||||
): Promise<{ name?: string; size?: number }[] | null> {
|
||||
const list = lists[index];
|
||||
if (!list) return null;
|
||||
let res: Response | null = null;
|
||||
try {
|
||||
res = await this.getModelList(list.url, root, apiKey);
|
||||
} catch (err) {
|
||||
if (index === 0) throw err;
|
||||
}
|
||||
if (res) {
|
||||
seen.firstStatus ||= res.status;
|
||||
if (res.status === 401 || res.status === 403) seen.refusedStatus ||= res.status;
|
||||
if (res.ok) {
|
||||
const listed = list.read(await readCappedJson<unknown>(res, MAX_LIST_BYTES).catch(() => undefined));
|
||||
if (listed) return listed;
|
||||
} else {
|
||||
// Neither list exists: report the first answer, as before.
|
||||
discardBody(fallback);
|
||||
discardBody(res);
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
discardBody(res);
|
||||
throw upstreamRefusal(res.status, apiKey, `Local LLM server error (${res.status})`);
|
||||
}
|
||||
const body = await readCappedJson<unknown>(res, MAX_LIST_BYTES);
|
||||
const listed = openAi
|
||||
? openAiModelsSchema.safeParse(body).data?.data.map((m) => ({ name: m.id, size: 0 }))
|
||||
: tagsResponseSchema.safeParse(body).data?.models;
|
||||
if (!listed) throw new DomainError(502, 'Local LLM server sent an unreadable model list');
|
||||
const models = listed.map((m) => ({ name: m.name ?? '', size: m.size ?? 0 })).filter((m) => m.name);
|
||||
return { models };
|
||||
return this.firstModelList(lists, index + 1, root, apiKey, seen);
|
||||
}
|
||||
|
||||
private async getModelList(url: string, root: string, apiKey: string | undefined): Promise<Response> {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Minimal Ollama native-API client used by the extraction router.
|
||||
* Minimal Ollama native-API client used by the extraction router. A server that
|
||||
* has no `/api/chat` gets the same extraction over OpenAI-compatible
|
||||
* `/chat/completions` instead (see extractOpenAiCompatible below).
|
||||
*
|
||||
* Why not the OpenAI-compatible `/v1/chat/completions` path the rest of llm-parse uses?
|
||||
* Ollama's `/v1` endpoint does NOT faithfully honour OpenAI's `response_format:{json_schema,strict}`
|
||||
@@ -12,7 +14,9 @@
|
||||
*/
|
||||
import { readEnv } from '../../../app-config';
|
||||
import { safeFetchLlm } from '../../../utils/ssrfGuard';
|
||||
import { OpenAiCompatibleClient } from '../clients/openai-compatible.client';
|
||||
import { parseLenientJson } from '../lenient-json';
|
||||
import type { LlmExtractionFile } from '../llm-provider.interface';
|
||||
|
||||
export interface EnforcedExtractInput {
|
||||
/** Ollama base URL — accepts the addon's `…/v1` form; the `/v1` suffix is stripped. */
|
||||
@@ -40,6 +44,13 @@ export function toNativeBase(baseUrl: string): string {
|
||||
return baseUrl.slice(0, end).replace(/\/v1$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The answers that say the server has no `/api/chat` at all, unless the body is
|
||||
* Ollama's own 404 for a model it has not pulled (OLLAMA_MISSING_MODEL).
|
||||
*/
|
||||
const NOT_OLLAMA_STATUSES = new Set([404, 405, 501]);
|
||||
const OLLAMA_MISSING_MODEL = /\bmodel\b.*\bnot found\b/i;
|
||||
|
||||
/**
|
||||
* Run one schema-constrained chat completion against Ollama's native `/api/chat`.
|
||||
* Returns the parsed JSON object (constrained to `schema`), or null if the request
|
||||
@@ -85,10 +96,61 @@ export async function extractEnforced(input: EnforcedExtractInput): Promise<Reco
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '');
|
||||
if (NOT_OLLAMA_STATUSES.has(res.status) && !OLLAMA_MISSING_MODEL.test(detail))
|
||||
return extractOpenAiCompatible(input);
|
||||
throw new Error(`Ollama /api/chat failed (${res.status}): ${detail.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { message?: { content?: string } };
|
||||
const data = (await res.json().catch(() => null)) as { message?: { content?: string } } | null;
|
||||
// LM Studio answers a path it does not serve with 200 and an error object,
|
||||
// so an answer without Ollama's `message` is a server of another kind too.
|
||||
if (!data || typeof data.message !== 'object') return extractOpenAiCompatible(input);
|
||||
const parsed = parseLenientJson(data.message?.content);
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same extraction against an OpenAI-compatible server (Groq, vLLM,
|
||||
* LM Studio, a LiteLLM proxy), which the "Own server" connection also names.
|
||||
* It goes to `/chat/completions` at the address as entered, the endpoint the
|
||||
* assistant chats on, through the client that already handles what such
|
||||
* servers refuse (`json_schema`, `max_tokens`, `temperature`). The schema is
|
||||
* asked for as a one-element list, the shape that client reads (#2806).
|
||||
*/
|
||||
async function extractOpenAiCompatible(input: EnforcedExtractInput): Promise<Record<string, unknown> | null> {
|
||||
const [file, ...pageImages] = (input.images ?? []).map(imageFile);
|
||||
const list = await new OpenAiCompatibleClient().extract({
|
||||
// The shape goes into the prompt as well: the client's later rungs send no
|
||||
// schema (json_object, then nothing), and json_object wants the word JSON.
|
||||
prompt:
|
||||
`${input.system} Answer with JSON only, as {"result": [object]} where the object follows this ` +
|
||||
`JSON Schema: ${JSON.stringify(input.schema)}`,
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: { result: { type: 'array', items: input.schema } },
|
||||
required: ['result'],
|
||||
},
|
||||
rootKey: 'result',
|
||||
userText: input.user,
|
||||
model: input.model,
|
||||
baseUrl: input.baseUrl,
|
||||
apiKey: input.apiKey,
|
||||
file,
|
||||
pageImages,
|
||||
});
|
||||
const first = list[0];
|
||||
return first && typeof first === 'object' ? first : null;
|
||||
}
|
||||
|
||||
/** The first characters of a base64 image, per type. Anything else is sent as JPEG. */
|
||||
const IMAGE_PREFIXES: [string, string][] = [
|
||||
['iVBOR', 'image/png'],
|
||||
['UklGR', 'image/webp'],
|
||||
['R0lGOD', 'image/gif'],
|
||||
];
|
||||
|
||||
/** A base64 image as a file, its type read from its first bytes. */
|
||||
function imageFile(base64: string): LlmExtractionFile {
|
||||
const mimeType = IMAGE_PREFIXES.find(([prefix]) => base64.startsWith(prefix))?.[1] ?? 'image/jpeg';
|
||||
return { mimeType, data: Buffer.from(base64, 'base64') };
|
||||
}
|
||||
|
||||
@@ -86,11 +86,67 @@ export async function reclaimPhotoCache(
|
||||
|
||||
export interface DedupSet {
|
||||
names: Set<string>;
|
||||
/**
|
||||
* The places with a line, keyed by name and course (`trackKey`): a track to import
|
||||
* matches only these (#2810), and only one that runs the same way (#2870). Absent
|
||||
* where none is read.
|
||||
*/
|
||||
tracks?: Set<string>;
|
||||
coords: Array<{ lat: number; lng: number }>;
|
||||
/** Provider ids (google_place_id, google_ftid, osm_id, amap_poi_id) of the places already in the trip. */
|
||||
externalIds: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A candidate for the dedup set; `track` when it is a line (a GPX track or route, a
|
||||
* KML path), with that line in `routeGeometry`.
|
||||
*/
|
||||
export type DedupCandidate = PlaceMatchCandidate & { track?: boolean; routeGeometry?: string | null };
|
||||
|
||||
/**
|
||||
* Where a line starts, ends and how far it reaches, to about a hundred metres: two
|
||||
* hikes a watch names alike ("Afternoon Hike") differ here, while the same file read
|
||||
* again gives the same answer however its points were rounded or thinned. Empty for
|
||||
* a line that does not parse.
|
||||
*/
|
||||
function lineCourse(routeGeometry: string | null | undefined): string {
|
||||
let points: unknown;
|
||||
try {
|
||||
points = JSON.parse(routeGeometry ?? '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
if (!Array.isArray(points)) return '';
|
||||
const line = points.filter(
|
||||
(p): p is [number, number] => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]),
|
||||
);
|
||||
if (line.length === 0) return '';
|
||||
// A loop rather than Math.min(...): a recorded day holds more points than a call takes arguments.
|
||||
let [south, west] = line[0];
|
||||
let [north, east] = line[0];
|
||||
for (const [lat, lng] of line) {
|
||||
south = Math.min(south, lat);
|
||||
north = Math.max(north, lat);
|
||||
west = Math.min(west, lng);
|
||||
east = Math.max(east, lng);
|
||||
}
|
||||
const [first, last] = [line[0], line[line.length - 1]];
|
||||
return [first[0], first[1], last[0], last[1], south, west, north, east].map((v) => v.toFixed(3)).join(',');
|
||||
}
|
||||
|
||||
/** An imported row as the dedup set reads it: a line when it carries one. */
|
||||
export function lineCandidate(
|
||||
name: string | null | undefined,
|
||||
row: { lat: number | null; lng: number | null; routeGeometry?: string | null },
|
||||
): DedupCandidate {
|
||||
return { name, lat: row.lat, lng: row.lng, track: !!row.routeGeometry, routeGeometry: row.routeGeometry };
|
||||
}
|
||||
|
||||
/** A track's entry in `DedupSet.tracks`: its normalized name and its course. */
|
||||
function trackKey(name: string, routeGeometry: string | null | undefined): string {
|
||||
return `${name}\u0000${lineCourse(routeGeometry)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a candidate place is already represented in the dedup set.
|
||||
*
|
||||
@@ -104,12 +160,19 @@ export interface DedupSet {
|
||||
* JavaScript's is not, and only unnamed rows contribute coordinates to a dedup
|
||||
* set. `findDuplicatePlace` spells out where the two still answer differently.
|
||||
*/
|
||||
export function isPlaceDuplicate(candidate: PlaceMatchCandidate, dedup: DedupSet): boolean {
|
||||
export function isPlaceDuplicate(candidate: DedupCandidate, dedup: DedupSet): boolean {
|
||||
for (const strategy of placeMatchStrategies(candidate)) {
|
||||
if (strategy.by === 'externalId') {
|
||||
if (dedup.externalIds.has(strategy.id)) return true;
|
||||
} else if (strategy.by === 'name') {
|
||||
if (dedup.names.has(strategy.name)) return true;
|
||||
// A track is the same as a track of that name that runs the same way, never as a
|
||||
// sight of that name: a watch names a hike after the place it went to (#2810), and
|
||||
// two hikes of one day alike (#2870).
|
||||
if (candidate.track) {
|
||||
if (dedup.tracks?.has(trackKey(strategy.name, candidate.routeGeometry))) return true;
|
||||
} else if (dedup.names.has(strategy.name)) {
|
||||
return true;
|
||||
}
|
||||
} else if (
|
||||
dedup.coords.some(
|
||||
(c) =>
|
||||
@@ -130,29 +193,34 @@ export function isPlaceDuplicate(candidate: PlaceMatchCandidate, dedup: DedupSet
|
||||
*/
|
||||
export function dedupSetOf(
|
||||
rows: ReadonlyArray<
|
||||
{ name: string | null; lat: number | null; lng: number | null } & Parameters<typeof externalIdsOf>[0]
|
||||
{ name: string | null; lat: number | null; lng: number | null; route_geometry?: string | null } & Parameters<
|
||||
typeof externalIdsOf
|
||||
>[0]
|
||||
>,
|
||||
): DedupSet {
|
||||
const names = new Set<string>();
|
||||
const tracks = new Set<string>();
|
||||
const coords: Array<{ lat: number; lng: number }> = [];
|
||||
const externalIds = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const id of externalIdsOf(row)) externalIds.add(id);
|
||||
if (row.name) {
|
||||
names.add(row.name.trim().toLowerCase());
|
||||
if (row.route_geometry) tracks.add(trackKey(row.name.trim().toLowerCase(), row.route_geometry));
|
||||
} else if (row.lat != null && row.lng != null) {
|
||||
coords.push({ lat: row.lat, lng: row.lng });
|
||||
}
|
||||
}
|
||||
return { names, coords, externalIds };
|
||||
return { names, tracks, coords, externalIds };
|
||||
}
|
||||
|
||||
/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */
|
||||
export function trackInsertedInDedupSet(place: PlaceMatchCandidate, dedup: DedupSet): void {
|
||||
export function trackInsertedInDedupSet(place: DedupCandidate, dedup: DedupSet): void {
|
||||
for (const id of externalIdsOf(place)) dedup.externalIds.add(id);
|
||||
const normalizedName = normalizePlaceName(place.name);
|
||||
if (normalizedName) {
|
||||
dedup.names.add(normalizedName);
|
||||
if (place.track) (dedup.tracks ??= new Set()).add(trackKey(normalizedName, place.routeGeometry));
|
||||
} else if (place.lat != null && place.lng != null) {
|
||||
dedup.coords.push({ lat: place.lat, lng: place.lng });
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
buildCategoryNameLookup,
|
||||
escapeLikePattern,
|
||||
dedupSetOf,
|
||||
isPlaceDuplicate,
|
||||
isPlaceDuplicate, lineCandidate,
|
||||
mapWithConcurrency,
|
||||
pickEnrichmentMatch,
|
||||
reclaimPhotoCache,
|
||||
@@ -758,7 +758,7 @@ export class PlacesService {
|
||||
async gpxDuplicates(tripId: string, rows: PreparedGpxPlace[]): Promise<boolean[]> {
|
||||
const dedup = await this.buildDedupSet(tripId);
|
||||
return rows.map((wp) => {
|
||||
const candidate = { name: wp.name, lat: wp.lat, lng: wp.lng };
|
||||
const candidate = lineCandidate(wp.name, wp);
|
||||
if (isPlaceDuplicate(candidate, dedup)) return true;
|
||||
trackInsertedInDedupSet(candidate, dedup);
|
||||
return false;
|
||||
@@ -925,7 +925,7 @@ export class PlacesService {
|
||||
// `findWithTagsAndRatings` INSIDE the caller's transaction, so the read
|
||||
// sees the still-uncommitted row.
|
||||
for (const wp of waypoints) {
|
||||
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
|
||||
if (isPlaceDuplicate(lineCandidate(wp.name, wp), dedup)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@@ -960,7 +960,7 @@ export class PlacesService {
|
||||
});
|
||||
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
|
||||
trackInsertedInDedupSet(lineCandidate(wp.name, wp), dedup);
|
||||
}
|
||||
|
||||
return { places: created, count: created.length, skipped };
|
||||
@@ -1033,7 +1033,7 @@ export class PlacesService {
|
||||
const fallbackName = `Placemark ${fallbackIndex}`;
|
||||
const name = parsedPlacemark.name || fallbackName;
|
||||
|
||||
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup)) {
|
||||
if (isPlaceDuplicate(lineCandidate(name, parsedPlacemark), dedup)) {
|
||||
summary.skippedCount += 1;
|
||||
dupCount++;
|
||||
fallbackIndex += 1;
|
||||
@@ -1079,7 +1079,7 @@ export class PlacesService {
|
||||
|
||||
const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!;
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup);
|
||||
trackInsertedInDedupSet(lineCandidate(name, parsedPlacemark), dedup);
|
||||
summary.createdCount += 1;
|
||||
fallbackIndex += 1;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,10 @@ export function activityFacts(
|
||||
}
|
||||
|
||||
/** The times an import stores for a track, as the DATETIME columns hold them; NULL without. */
|
||||
function storedTimes(row: PreparedGpxPlace | undefined): { started: DbTimestamp | null; ended: DbTimestamp | null } {
|
||||
export function recordedTimesOf(row: PreparedGpxPlace | undefined): {
|
||||
started: DbTimestamp | null;
|
||||
ended: DbTimestamp | null;
|
||||
} {
|
||||
const times = row?.trackTimes;
|
||||
return times
|
||||
? { started: dbNow(new Date(times.startedAt)), ended: dbNow(new Date(times.endedAt)) }
|
||||
@@ -93,7 +96,7 @@ export function trackTimesFor(
|
||||
(row, at) => !taken.has(at) && row.name === place.name && row.lat === place.lat && row.lng === place.lng,
|
||||
);
|
||||
if (index >= 0) taken.add(index);
|
||||
byPlace.set(place.id, storedTimes(rows[index]));
|
||||
byPlace.set(place.id, recordedTimesOf(rows[index]));
|
||||
}
|
||||
return byPlace;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PreparedGpxPlace } from '../place-import/place-import.types';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { readTourFile } from './tour-file-import.helpers';
|
||||
import { gpxOfLine, splitGpxTrackPieces } from './tour-file-split.helpers';
|
||||
import { tracksToAdopt } from './tour-track-adoption';
|
||||
import { computeTourMetrics, parseRouteGeometry } from './tours.helpers';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TOUR_GPX_TEXT_MAX, type TourFileLeftOut } from '@trek/shared';
|
||||
@@ -15,7 +16,10 @@ export interface TourFileTrack {
|
||||
point_count: number;
|
||||
distance_km: number;
|
||||
has_heights: boolean;
|
||||
/** A track already in the trip under the same name and start, which the import would skip. */
|
||||
/**
|
||||
* A track already in the trip under the same name and start, which the import would skip. A track
|
||||
* from before Tours under the same name is none: the import takes it over as a tour (#2810).
|
||||
*/
|
||||
duplicate: boolean;
|
||||
}
|
||||
|
||||
@@ -55,7 +59,9 @@ export class TourFileReadService {
|
||||
async read(tripId: number, buffer: Buffer, fileName: string): Promise<TourFileTracks> {
|
||||
const { rows, leftOut } = await readTourFile(this.placeImport, buffer, fileName);
|
||||
if (rows.length === 0) return { tracks: [], whole: false };
|
||||
const duplicates = await this.places.gpxDuplicates(String(tripId), rows);
|
||||
const found = await this.places.gpxDuplicates(String(tripId), rows);
|
||||
const adopt = tracksToAdopt(rows, await this.places.list(String(tripId), {}));
|
||||
const duplicates = found.map((duplicate, index) => duplicate && !adopt.has(index));
|
||||
const extra = leftOut ? { left_out: leftOut } : {};
|
||||
if (!isGpxName(fileName)) {
|
||||
const tracks = rows.map((row, index) =>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { normalizePlaceName } from '@trek/shared';
|
||||
|
||||
/** A place of the trip as the places list reads it: enough to tell a track that is no tour yet. */
|
||||
export interface TripPlaceForAdoption {
|
||||
id: number;
|
||||
name: string;
|
||||
route_geometry?: string | null;
|
||||
tour_place_id?: number | null;
|
||||
}
|
||||
|
||||
/** A track of the trip a file row takes over: its place and the line it already holds. */
|
||||
export interface AdoptedTrack {
|
||||
id: number;
|
||||
route_geometry: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows of a tour import that take over a track already in the trip, by
|
||||
* row index: a place with a line and no tour yet, under the same name
|
||||
* (trimmed, any case, the duplicate rule's own reading), one place per row
|
||||
* and oldest first. A GPX file imported before Tours existed (4.x) left its
|
||||
* tracks as plain places, and the name alone made every tour import of the
|
||||
* same file a duplicate, with no tour to show for it (#2810).
|
||||
*/
|
||||
export function tracksToAdopt(
|
||||
rows: ReadonlyArray<{ name: string }>,
|
||||
places: ReadonlyArray<TripPlaceForAdoption>,
|
||||
): Map<number, AdoptedTrack> {
|
||||
const byName = new Map<string, AdoptedTrack[]>();
|
||||
for (const place of [...places].sort((a, b) => a.id - b.id)) {
|
||||
const key = normalizePlaceName(place.name);
|
||||
if (!key || !place.route_geometry || place.tour_place_id != null) continue;
|
||||
byName.set(key, [...(byName.get(key) ?? []), { id: place.id, route_geometry: place.route_geometry }]);
|
||||
}
|
||||
const adopt = new Map<number, AdoptedTrack>();
|
||||
rows.forEach((row, index) => {
|
||||
const key = normalizePlaceName(row.name);
|
||||
const track = key ? byName.get(key)?.shift() : undefined;
|
||||
if (track) adopt.set(index, track);
|
||||
});
|
||||
return adopt;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { toRowId } from '../common/row-id';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { PlaceImportService } from '../place-import/place-import.service';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { toActivity, trackTimesFor } from './tour-activities.helpers';
|
||||
import { recordedTimesOf, toActivity, trackTimesFor } from './tour-activities.helpers';
|
||||
import {
|
||||
copiedFacet,
|
||||
copyStart,
|
||||
@@ -29,6 +29,7 @@ import { readTourFile } from './tour-file-import.helpers';
|
||||
import { provenanceSource, tourItemOf } from './tour-item.helpers';
|
||||
import { TourPaddleCheckService } from './tour-paddle-check.service';
|
||||
import { advanceTourRevision } from './tour-revision';
|
||||
import { tracksToAdopt } from './tour-track-adoption';
|
||||
import { toVisit } from './tour-visits.service';
|
||||
import { checkTourCommand, firstPointOf, importFileName, keyedWaypoints, tourRouteFields } from './tour-write.helpers';
|
||||
import { computeTourMetrics, localHashOf, parseRouteGeometry, type GeometryPoint } from './tours.helpers';
|
||||
@@ -58,6 +59,30 @@ import {
|
||||
wandererDifficultyToSac,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** `fn` over `items` one after the other, in order: the writes of one import share a transaction. */
|
||||
async function inOrder<T, R>(items: readonly T[], fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
return items.reduce<Promise<R[]>>(async (done, item) => [...(await done), await fn(item)], Promise.resolve([]));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the type adds to each tour a file import makes: a bike tour and a run
|
||||
* their profile, a paddle tour its boat and water, a ski track its discipline
|
||||
* and technique (SKI-02). A run comes in without a pace and a ski track
|
||||
* without a time: neither is worked out for them, so they stay unknown.
|
||||
*/
|
||||
function importedTypeFields(fields: TourGpxImportFields) {
|
||||
return {
|
||||
bike_profile: fields.bike_profile ?? null,
|
||||
run_profile: fields.run_profile ?? null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: fields.ski_discipline ?? null,
|
||||
ski_technique: fields.ski_technique ?? null,
|
||||
boat_type: fields.boat_type ?? null,
|
||||
water_context: fields.water_context ?? null,
|
||||
paddle_speed_kmh: null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImportGpxAsTourResult {
|
||||
tours: TourListItem[];
|
||||
caution: boolean;
|
||||
@@ -783,88 +808,45 @@ export class ToursService {
|
||||
const { rows } = await readTourFile(this.placeImport, fileBuffer, fileName);
|
||||
if (rows.length === 0) return null;
|
||||
await this.assertTourTypeEnabled(fields.tour_type);
|
||||
// What the type adds to each imported tour: a bike tour and a run their profile, a paddle tour its boat and water,
|
||||
// a ski track its discipline and technique (SKI-02). A run comes in without a pace and a ski track without a
|
||||
// time: neither is worked out for them, so they stay unknown.
|
||||
const typeFields = {
|
||||
bike_profile: fields.bike_profile ?? null,
|
||||
run_profile: fields.run_profile ?? null,
|
||||
run_pace_seconds_per_km: null,
|
||||
ski_discipline: fields.ski_discipline ?? null,
|
||||
ski_technique: fields.ski_technique ?? null,
|
||||
boat_type: fields.boat_type ?? null,
|
||||
water_context: fields.water_context ?? null,
|
||||
paddle_speed_kmh: null,
|
||||
};
|
||||
const typeFields = importedTypeFields(fields);
|
||||
|
||||
const adopt = await this.adoptableTracks(tripId, rows);
|
||||
const fresh = rows.filter((_, index) => !adopt.has(index));
|
||||
const tours: TourListItem[] = [];
|
||||
const adopted: number[] = [];
|
||||
const result = await this.uow.transactional(async () => {
|
||||
// A nested transactional, so the places and their facets commit together.
|
||||
const imported = await this.places.importPreparedGpx(tripId, rows);
|
||||
const times = trackTimesFor(rows, imported.places);
|
||||
for (const place of imported.places) {
|
||||
const recorded = times.get(place.id);
|
||||
const metrics = computeTourMetrics(parseRouteGeometry(place.route_geometry));
|
||||
const matchConfidence = metrics.hasElevation ? 1 : 0.3;
|
||||
await this.toursRepo.insertTour({
|
||||
place_id: place.id,
|
||||
tour_type: fields.tour_type,
|
||||
...typeFields,
|
||||
distance: metrics.distanceKm,
|
||||
elevation_gain: metrics.elevationGainM,
|
||||
elevation_loss: metrics.elevationLossM,
|
||||
duration: null,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: null,
|
||||
match_confidence: matchConfidence,
|
||||
max_hiking_difficulty: null,
|
||||
origin: 'gpx',
|
||||
origin_file_name: importFileName(fileName),
|
||||
track_started_at: recorded?.started ?? null,
|
||||
track_ended_at: recorded?.ended ?? null,
|
||||
});
|
||||
tours.push(
|
||||
tourItemOf({
|
||||
place_id: place.id,
|
||||
name: place.name,
|
||||
description: place.description ?? null,
|
||||
website: place.website ?? null,
|
||||
tour_type: fields.tour_type,
|
||||
...typeFields,
|
||||
distance: metrics.distanceKm,
|
||||
elevation_gain: metrics.elevationGainM,
|
||||
elevation_loss: metrics.elevationLossM,
|
||||
duration: null,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: matchConfidence,
|
||||
max_hiking_difficulty: null,
|
||||
derived_from_place_id: null,
|
||||
origin: 'gpx',
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
track_marks: null,
|
||||
has_recorded_track: 0,
|
||||
planned: 0,
|
||||
has_waypoints: 0,
|
||||
stage_ends: 0,
|
||||
route_missing: 0,
|
||||
revision: 1,
|
||||
hiking_preferences: null,
|
||||
track_started_at: recorded?.started ?? null,
|
||||
track_ended_at: recorded?.ended ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const imported =
|
||||
fresh.length > 0 ? await this.places.importPreparedGpx(tripId, fresh) : { places: [], count: 0, skipped: 0 };
|
||||
const times = trackTimesFor(fresh, imported.places);
|
||||
tours.push(
|
||||
...(await inOrder(imported.places, (place) =>
|
||||
this.insertImportedTour(
|
||||
place,
|
||||
place.route_geometry ?? null,
|
||||
times.get(place.id),
|
||||
fields,
|
||||
typeFields,
|
||||
fileName,
|
||||
),
|
||||
)),
|
||||
);
|
||||
// A track from before Tours keeps its place and its line and becomes the
|
||||
// tour; the file adds the recorded times 4.x never stored.
|
||||
await inOrder([...adopt], async ([index, track]) => {
|
||||
const place = await this.places.get(tripId, String(track.id));
|
||||
if (!place) return;
|
||||
const recorded = recordedTimesOf(rows[index]);
|
||||
tours.push(await this.insertImportedTour(place, track.route_geometry, recorded, fields, typeFields, fileName));
|
||||
adopted.push(place.id);
|
||||
});
|
||||
return imported;
|
||||
});
|
||||
|
||||
if (result.places.length === 0) return { tours: [], caution: false, skipped: result.skipped };
|
||||
if (tours.length === 0) return { tours: [], caution: false, skipped: result.skipped };
|
||||
this.broadcastToursChanged(
|
||||
tripId,
|
||||
result.places.map((place) => place.id),
|
||||
tours.map((tour) => tour.place_id),
|
||||
socketId,
|
||||
);
|
||||
for (const place of result.places) {
|
||||
@@ -874,9 +856,90 @@ export class ToursService {
|
||||
this.logger.warn(`Committed GPX place ${place.id}: realtime notification failed`);
|
||||
}
|
||||
}
|
||||
// The taken-over places now read as tours; the other clients get the row again.
|
||||
await inOrder(adopted, async (id) => {
|
||||
try {
|
||||
const place = await this.places.get(tripId, String(id));
|
||||
if (place) this.places.broadcast(tripId, 'place:updated', { place }, socketId);
|
||||
} catch {
|
||||
this.logger.warn(`Committed GPX tour on place ${id}: realtime notification failed`);
|
||||
}
|
||||
});
|
||||
return { tours, caution: tours.some((t) => t.caution), skipped: result.skipped };
|
||||
}
|
||||
|
||||
/** Which rows of a file take over a track of the trip from before Tours (#2810), read off the places list. */
|
||||
private async adoptableTracks(tripId: string, rows: ReadonlyArray<{ name: string }>) {
|
||||
return tracksToAdopt(rows, await this.places.list(tripId, {}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The `tours` row for one place of a GPX import, with its metrics read off
|
||||
* `geometry` and the recorded times, and the list item the import answers
|
||||
* with. A new place and a track taken over from before Tours (#2810) get the
|
||||
* same row.
|
||||
*/
|
||||
private async insertImportedTour(
|
||||
place: { id: number; name: string; description?: string | null; website?: string | null },
|
||||
geometry: string | null,
|
||||
recorded: ReturnType<typeof recordedTimesOf> | undefined,
|
||||
fields: TourGpxImportFields,
|
||||
typeFields: ReturnType<typeof importedTypeFields>,
|
||||
fileName: string | undefined,
|
||||
): Promise<TourListItem> {
|
||||
const metrics = computeTourMetrics(parseRouteGeometry(geometry));
|
||||
const matchConfidence = metrics.hasElevation ? 1 : 0.3;
|
||||
await this.toursRepo.insertTour({
|
||||
place_id: place.id,
|
||||
tour_type: fields.tour_type,
|
||||
...typeFields,
|
||||
distance: metrics.distanceKm,
|
||||
elevation_gain: metrics.elevationGainM,
|
||||
elevation_loss: metrics.elevationLossM,
|
||||
duration: null,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: null,
|
||||
match_confidence: matchConfidence,
|
||||
max_hiking_difficulty: null,
|
||||
origin: 'gpx',
|
||||
origin_file_name: importFileName(fileName),
|
||||
track_started_at: recorded?.started ?? null,
|
||||
track_ended_at: recorded?.ended ?? null,
|
||||
});
|
||||
return tourItemOf({
|
||||
place_id: place.id,
|
||||
name: place.name,
|
||||
description: place.description ?? null,
|
||||
website: place.website ?? null,
|
||||
tour_type: fields.tour_type,
|
||||
...typeFields,
|
||||
distance: metrics.distanceKm,
|
||||
elevation_gain: metrics.elevationGainM,
|
||||
elevation_loss: metrics.elevationLossM,
|
||||
duration: null,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: matchConfidence,
|
||||
max_hiking_difficulty: null,
|
||||
derived_from_place_id: null,
|
||||
origin: 'gpx',
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
track_marks: null,
|
||||
has_recorded_track: 0,
|
||||
planned: 0,
|
||||
has_waypoints: 0,
|
||||
stage_ends: 0,
|
||||
route_missing: 0,
|
||||
revision: 1,
|
||||
hiking_preferences: null,
|
||||
track_started_at: recorded?.started ?? null,
|
||||
track_ended_at: recorded?.ended ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-02: what a file would import, read with the import's own parser and
|
||||
* duplicate rule and written nowhere. Null when the file holds no track or
|
||||
@@ -887,6 +950,7 @@ export class ToursService {
|
||||
const { rows, leftOut } = await readTourFile(this.placeImport, fileBuffer, fileName);
|
||||
if (rows.length === 0) return null;
|
||||
const duplicates = await this.places.gpxDuplicates(tripId, rows);
|
||||
const adopt = await this.adoptableTracks(tripId, rows);
|
||||
return {
|
||||
tracks: rows.map((row, index) => {
|
||||
const geometry = parseRouteGeometry(row.routeGeometry);
|
||||
@@ -900,7 +964,8 @@ export class ToursService {
|
||||
elevation_loss_m: metrics.elevationLossM,
|
||||
started_at: row.trackTimes?.startedAt ?? null,
|
||||
ended_at: row.trackTimes?.endedAt ?? null,
|
||||
duplicate: duplicates[index] ?? false,
|
||||
duplicate: !adopt.has(index) && (duplicates[index] ?? false),
|
||||
...(adopt.has(index) ? { adopts_track: true } : {}),
|
||||
};
|
||||
}),
|
||||
...(leftOut ? { left_out: leftOut } : {}),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { haversineKm } from '../common/geo';
|
||||
import { localParts, resolveTimeZone } from '../common/timezoneService';
|
||||
import type { EndpointInput } from '../reservations/reservations.service';
|
||||
import { deriveTransitStats, type TransitItinerary, type TransitLeg } from './transit.helpers';
|
||||
import type { TransitProvider } from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -205,10 +206,12 @@ function timezoneAt(lat: number, lng: number): string {
|
||||
return timezone;
|
||||
}
|
||||
|
||||
/** `provider` is the backend the itinerary came from, recorded with the journey. */
|
||||
export function buildTransitReservationParts(
|
||||
from: TransitPlaceInput,
|
||||
to: TransitPlaceInput,
|
||||
itinerary: TransitItinerary,
|
||||
provider: TransitProvider,
|
||||
) {
|
||||
// The same stop appears up to three times below (transfer endpoint + the
|
||||
// adjacent legs' metadata) — memoise the tz lookup per coordinate pair.
|
||||
@@ -276,7 +279,7 @@ export function buildTransitReservationParts(
|
||||
const stats = deriveTransitStats(itinerary.startTime, itinerary.endTime, itinerary.legs, itinerary.transfers);
|
||||
const metadata = {
|
||||
transit: {
|
||||
provider: 'transitous',
|
||||
provider,
|
||||
duration: stats.duration,
|
||||
transfers: stats.transfers,
|
||||
walk_seconds: stats.walkSeconds,
|
||||
|
||||
@@ -110,7 +110,7 @@ export class TransitMcp {
|
||||
@Tool({
|
||||
name: 'search_transit_routes',
|
||||
description:
|
||||
'Search scheduled public-transit routes between two coordinates, via whichever backend the instance is configured for. Returns itineraries that can be passed unchanged to create_transit_journey. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
|
||||
'Search scheduled public-transit routes between two coordinates, via whichever backend the instance is configured for. Returns itineraries that can be passed unchanged to create_transit_journey, and `provider`, the backend that answered. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.',
|
||||
inputSchema: {
|
||||
from: transitPlaceInput,
|
||||
to: transitPlaceInput,
|
||||
@@ -169,7 +169,7 @@ export class TransitMcp {
|
||||
// A rejected itinerary is provider data we could not vouch for, but dropping it
|
||||
// silently is indistinguishable from "no routes exist" — report the count so the
|
||||
// caller knows the difference.
|
||||
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length });
|
||||
return ok({ itineraries, dropped: result.itineraries.length - itineraries.length, provider: result.provider });
|
||||
} catch (err) {
|
||||
return errorResult(err, 'Transit route search failed.');
|
||||
}
|
||||
@@ -228,9 +228,11 @@ export class TransitMcp {
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
// The itinerary came from search_transit_routes, which the active backend answered.
|
||||
const provider = await this.transit.activeProvider(ctx.userId);
|
||||
let reservationParts: ReturnType<typeof buildTransitReservationParts>;
|
||||
try {
|
||||
reservationParts = buildTransitReservationParts(from, to, cleaned);
|
||||
reservationParts = buildTransitReservationParts(from, to, cleaned, provider);
|
||||
} catch (err) {
|
||||
return errorResult(err, 'Unable to resolve the transit journey timezones.');
|
||||
}
|
||||
|
||||
@@ -159,6 +159,11 @@ export class TransitService {
|
||||
@Inject(transitConfig.KEY) private readonly transitEnv: ConfigType<typeof transitConfig>,
|
||||
) {}
|
||||
|
||||
/** The backend that answers this user's searches: Google where it is set up and keyed, Transitous otherwise. */
|
||||
async activeProvider(userId = 0): Promise<TransitProvider> {
|
||||
return (await this.google.isActive(userId)) ? 'google' : 'transitous';
|
||||
}
|
||||
|
||||
/**
|
||||
* Station/place search for the from/to pickers. `near` biases results.
|
||||
*
|
||||
@@ -175,7 +180,7 @@ export class TransitService {
|
||||
const text = (query || '').trim();
|
||||
// Answered before either backend is consulted, so it reports the one that
|
||||
// WOULD have been asked rather than claiming nobody was.
|
||||
const provider: TransitProvider = (await this.google.isActive(userId)) ? 'google' : 'transitous';
|
||||
const provider = await this.activeProvider(userId);
|
||||
if (text.length < 2) return { results: [], provider };
|
||||
if (text.length > 200) {
|
||||
const e = new Error('Query too long') as Error & { status: number };
|
||||
@@ -259,7 +264,7 @@ export class TransitService {
|
||||
|
||||
// After validation on purpose: whichever backend answers, the caller is held
|
||||
// to the same coordinate/mode/transfer contract and gets the same 400s.
|
||||
if (await this.google.isActive(userId)) {
|
||||
if ((await this.activeProvider(userId)) === 'google') {
|
||||
return { ...(await this.google.plan(q, language, userId)), provider: 'google' };
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
titleKey: 'system_notice.release_notes.headline',
|
||||
bodyKey: 'system_notice.release_notes.intro',
|
||||
release: {
|
||||
version: '5.0.0',
|
||||
version: '5.0.1',
|
||||
eyebrowKey: 'system_notice.release_notes.eyebrow',
|
||||
headlineKey: 'system_notice.release_notes.headline',
|
||||
introKey: 'system_notice.release_notes.intro',
|
||||
@@ -95,7 +95,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
footnoteKey: 'system_notice.release_notes.footnote',
|
||||
notes: {
|
||||
labelKey: 'system_notice.release_notes.notes_label',
|
||||
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.0',
|
||||
href: 'https://github.com/liketrek/TREK/releases/tag/v5.0.1',
|
||||
},
|
||||
note: {
|
||||
eyebrowKey: 'system_notice.release_notes.note_eyebrow',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,16 @@ async function answer(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
let nestApp: INestApplication;
|
||||
let orm: MikroORM;
|
||||
let fake: HttpServer;
|
||||
let adminId: number;
|
||||
|
||||
/** The instance connection, pointed at the fake under `path`: `/v1`, or nothing as an Ollama address is often entered. */
|
||||
function connectAt(path: string) {
|
||||
const { port } = fake.address() as AddressInfo;
|
||||
return request(nestApp.getHttpServer())
|
||||
.put('/api/admin/ai/connection')
|
||||
.set('Cookie', sessionCookie(adminId))
|
||||
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}${path}`, model: 'fake-model' });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
fake = createServer((req, res) => {
|
||||
@@ -110,13 +120,8 @@ beforeAll(async () => {
|
||||
orm = nestApp.get(MikroORM);
|
||||
await setAddonEnabled(orm, 'ai_assistant', true);
|
||||
await setAddonEnabled(orm, 'atlas', true);
|
||||
const adminId = (await makeUser(orm, { role: 'admin' })).user.id;
|
||||
const { port } = fake.address() as AddressInfo;
|
||||
const res = await request(nestApp.getHttpServer())
|
||||
.put('/api/admin/ai/connection')
|
||||
.set('Cookie', sessionCookie(adminId))
|
||||
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/v1`, model: 'fake-model' });
|
||||
expect(res.status).toBe(200);
|
||||
adminId = (await makeUser(orm, { role: 'admin' })).user.id;
|
||||
expect((await connectAt('/v1')).status).toBe(200);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -342,4 +347,23 @@ describe('Home assistant e2e', () => {
|
||||
expect(await countRows(orm, AssistantThreads, { id: homeThread })).toBe(0);
|
||||
expect(await countRows(orm, AssistantThreads, { id: tripThread })).toBe(1);
|
||||
});
|
||||
|
||||
it('ASTHE2E-007: an Ollama address entered without /v1 chats at /v1/chat/completions, where Ollama answers', async () => {
|
||||
const h = await ownerWithTrips();
|
||||
expect((await connectAt('')).status).toBe(200);
|
||||
try {
|
||||
provider.script = [say('Hello from Ollama.')];
|
||||
const res = await post(`${BASE}/chat`, h.userId, { ...CTX, threadId: null, text: 'hi' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(provider.requests).toHaveLength(1);
|
||||
expect(
|
||||
ofType(res.chunks, 'text-delta')
|
||||
.map((c) => c.delta)
|
||||
.join(''),
|
||||
).toBe('Hello from Ollama.');
|
||||
expect(ofType(res.chunks, 'error')).toEqual([]);
|
||||
} finally {
|
||||
expect((await connectAt('/v1')).status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -535,6 +535,67 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
expect(await countRows(orm, Tours)).toBe(1);
|
||||
});
|
||||
|
||||
it('imports two different tracks of the same name, and still skips either one the second time (#2870)', async () => {
|
||||
// A second hike the watch called "Alpine loop" too, somewhere else entirely.
|
||||
const other = TRACK_GPX.replace(/lat="49(\.\d+)?" lon="12/g, (m) => m.replace('49', '47').replace('12', '11'));
|
||||
expect((await importGpx(OWNER, TRACK_GPX)).body).toMatchObject({ skipped: 0 });
|
||||
expect((await previewGpx(OWNER, other)).body.tracks[0]).toMatchObject({ duplicate: false });
|
||||
const second = await importGpx(OWNER, other);
|
||||
expect(second.status).toBe(201);
|
||||
expect(second.body).toMatchObject({ skipped: 0 });
|
||||
expect(second.body.tours).toHaveLength(1);
|
||||
expect(await countRows(orm, Tours)).toBe(2);
|
||||
|
||||
expect((await importGpx(OWNER, TRACK_GPX)).body).toEqual({ tours: [], caution: false, skipped: 1 });
|
||||
expect((await importGpx(OWNER, other)).body).toEqual({ tours: [], caution: false, skipped: 1 });
|
||||
expect(await countRows(orm, Tours)).toBe(2);
|
||||
});
|
||||
|
||||
it('takes over a track from before Tours with the same name instead of skipping it (#2810)', async () => {
|
||||
// 4.x stored a GPX track as a plain place: a line, no tours row.
|
||||
const line = '[[49,12,100],[49.01,12.01,150],[49.02,12.02,120]]';
|
||||
const legacy = (await makePlace(orm, 5, { name: 'Alpine Loop ', lat: 49, lng: 12, route_geometry: line })).id;
|
||||
const elsewhere = (await makePlace(orm, 6, { name: 'Alpine loop', lat: 49, lng: 12, route_geometry: line })).id;
|
||||
|
||||
const preview = await previewGpx(OWNER, TRACK_GPX);
|
||||
expect(preview.status).toBe(200);
|
||||
expect(preview.body.tracks[0]).toMatchObject({ duplicate: false, adopts_track: true });
|
||||
expect(await countRows(orm, Tours)).toBe(0);
|
||||
|
||||
const res = await importGpx(OWNER, TRACK_GPX);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({ caution: false, skipped: 0 });
|
||||
expect(res.body.tours.map((tour: { place_id: number }) => tour.place_id)).toEqual([legacy]);
|
||||
expect(await countRows(orm, Places, { trip: 5 })).toBe(1);
|
||||
expect(await placeColumns(legacy, 'name', 'route_geometry')).toEqual({
|
||||
name: 'Alpine Loop ',
|
||||
route_geometry: line,
|
||||
});
|
||||
expect(await findRow(orm, Tours, { place: legacy })).toMatchObject({ tour_type: 'hike', origin: 'gpx' });
|
||||
expect(await findRow(orm, Tours, { place: elsewhere })).toBeNull();
|
||||
expect(broadcast.mock.calls.map((c) => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:updated']));
|
||||
expect(broadcast.mock.calls.map((c) => c[1])).not.toContain('place:created');
|
||||
|
||||
// Now a tour, the same file is a duplicate again.
|
||||
expect((await previewGpx(OWNER, TRACK_GPX)).body.tracks[0]).toMatchObject({ duplicate: true });
|
||||
expect((await importGpx(OWNER, TRACK_GPX)).body).toEqual({ tours: [], caution: false, skipped: 1 });
|
||||
});
|
||||
|
||||
it('imports a hike named after a sight of the trip instead of skipping it as a duplicate (#2810)', async () => {
|
||||
// A watch names the hike after where it went, and that place is already planned.
|
||||
const sight = (await makePlace(orm, 5, { name: 'alpine loop', lat: 47, lng: 11 })).id;
|
||||
|
||||
const preview = await previewGpx(OWNER, TRACK_GPX);
|
||||
expect(preview.body.tracks[0]).toMatchObject({ name: 'Alpine loop', duplicate: false });
|
||||
|
||||
const res = await importGpx(OWNER, TRACK_GPX);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({ skipped: 0 });
|
||||
expect(res.body.tours).toHaveLength(1);
|
||||
expect(res.body.tours[0].place_id).not.toBe(sight);
|
||||
expect(await placeColumns(sight, 'route_geometry')).toEqual({ route_geometry: null });
|
||||
});
|
||||
|
||||
it('400 without a file, and for a file with no track or route', async () => {
|
||||
const missing = await importGpx(OWNER, null);
|
||||
expect(missing.status).toBe(400);
|
||||
|
||||
@@ -574,20 +574,21 @@ describe('PlacesRepository.listForGpx (PL29) and existsByImageUrl (PI1)', () =>
|
||||
* by, the service-level dedup tests.
|
||||
*/
|
||||
describe('PlacesRepository.listDedupInputs (PL24)', () => {
|
||||
it('PLACEREPO-048: projects exactly the seven dedup columns, scoped to the trip', async () => {
|
||||
it('PLACEREPO-048: projects exactly the eight dedup columns, the line included (#2810), scoped to the trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const other = createTrip(testDb, user.id);
|
||||
await insertRow(t.orm, Places, {
|
||||
trip: trip.id, name: 'Louvre', lat: 48.86, lng: 2.34, google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1',
|
||||
route_geometry: '[[48.86,2.34],[48.87,2.35]]',
|
||||
});
|
||||
await insertRow(t.orm, Places, { trip: trip.id, name: '', lat: 1.1, lng: 2.2 });
|
||||
createPlace(testDb, other.id, { name: 'Not this trip' });
|
||||
|
||||
const rows = await places.listDedupInputs(String(trip.id));
|
||||
expect(rows).toEqual([
|
||||
{ name: 'Louvre', lat: 48.86, lng: 2.34, google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1' },
|
||||
{ name: '', lat: 1.1, lng: 2.2, google_place_id: null, google_ftid: null, osm_id: null, amap_poi_id: null },
|
||||
{ name: 'Louvre', lat: 48.86, lng: 2.34, route_geometry: '[[48.86,2.34],[48.87,2.35]]', google_place_id: 'gp1', google_ftid: 'ft1', osm_id: 'osm1', amap_poi_id: 'amap1' },
|
||||
{ name: '', lat: 1.1, lng: 2.2, route_geometry: null, google_place_id: null, google_ftid: null, osm_id: null, amap_poi_id: null },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -99,6 +99,43 @@ describe('Tool: create_day_note', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('describes text as the title and time as the body, as the planner shows them', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const { tools } = await h.client.listTools();
|
||||
for (const name of ['create_day_note', 'update_day_note']) {
|
||||
const props = tools.find((t) => t.name === name)?.inputSchema.properties as Record<string, unknown>;
|
||||
// The nullable body may come out as an anyOf, so read the property whole.
|
||||
const time = JSON.stringify(props.time);
|
||||
expect(JSON.stringify(props.text)).toMatch(/title/i);
|
||||
expect(time).toMatch(/body/i);
|
||||
expect(time).not.toMatch(/time label/i);
|
||||
expect(time).toContain('"maxLength":2000');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('takes a body of 2000 characters, the bound the planner allows, and refuses one more', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const body = 'b'.repeat(2000);
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, text: 'Museum day', time: body },
|
||||
});
|
||||
const data = parseToolResult(result) as { note: { id: number } };
|
||||
expect((await dayNote(data.note.id)).time).toBe(body);
|
||||
const tooLong = await h.client.callTool({
|
||||
name: 'create_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, text: 'Museum day', time: `${body}b` },
|
||||
});
|
||||
expect(tooLong.isError).toBe(true);
|
||||
expect(await countRows(orm, DayNotes, { day: day.id })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults icon to 📝', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -220,6 +257,45 @@ describe('Tool: update_day_note', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('edits a note whose body is longer than 250 characters without shortening it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const longBody = 'Opening hours and the ticket links. '.repeat(20);
|
||||
const note = createDayNote(testDb, day.id, trip.id, { text: 'Museum day', time: longBody });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const renamed = await h.client.callTool({
|
||||
name: 'update_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'Museums' },
|
||||
});
|
||||
expect(renamed.isError).toBeFalsy();
|
||||
const longer = `${longBody}Bring the pass.`;
|
||||
const rewritten = await h.client.callTool({
|
||||
name: 'update_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, time: longer },
|
||||
});
|
||||
expect(rewritten.isError).toBeFalsy();
|
||||
const row = await dayNote(note.id);
|
||||
expect(row.text).toBe('Museums');
|
||||
expect(row.time).toBe(longer);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an empty title on update and keeps the stored one', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id);
|
||||
const note = createDayNote(testDb, day.id, trip.id, { text: 'Keep me' });
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_day_note',
|
||||
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: '' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect((await dayNote(note.id)).text).toBe('Keep me');
|
||||
});
|
||||
});
|
||||
|
||||
it('trims text whitespace', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -201,6 +201,7 @@ describe('MCP transit tools', () => {
|
||||
expect(routes.itineraries[0].legs[0].from.name).toBe('Namba');
|
||||
expect(routes.itineraries[0].legs[1].to.name).toBe('Umeda');
|
||||
expect(routes.itineraries).toHaveLength(1);
|
||||
expect(routes.provider).toBe('transitous');
|
||||
expect(planMock).toHaveBeenCalledWith(expect.objectContaining({ modes: 'SUBWAY' }), undefined, user.id);
|
||||
|
||||
const invalidNear = await harness.client.callTool({
|
||||
@@ -244,6 +245,27 @@ describe('MCP transit tools', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('records Google as the provider of a journey planned while Google answers searches (#2850)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-12-03', end_date: '2026-12-04' });
|
||||
const day = await dayOf(trip.id, '2026-12-03');
|
||||
const active = vi.spyOn(TransitService.prototype, 'activeProvider').mockResolvedValue('google');
|
||||
try {
|
||||
await withHarness(user.id, ['reservations:write'], async (harness) => {
|
||||
const result = parseToolResult(
|
||||
await harness.client.callTool({
|
||||
name: 'create_transit_journey',
|
||||
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary },
|
||||
}),
|
||||
) as { reservation: { metadata: string } };
|
||||
expect(JSON.parse(result.reservation.metadata).transit.provider).toBe('google');
|
||||
});
|
||||
expect(active).toHaveBeenCalledWith(user.id);
|
||||
} finally {
|
||||
active.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects dateless, mismatched, and out-of-range journey dates', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const datelessTrip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
aiHostOf,
|
||||
aiRefusal,
|
||||
openrouterOAuthAvailable,
|
||||
ownServerChatBase,
|
||||
providerRefusal,
|
||||
} from '../../../../src/nest/ai-connections/ai-connections.constants';
|
||||
import { InMemoryAiRefreshLock } from '../../../../src/nest/ai-connections/ai-refresh-lock';
|
||||
@@ -127,6 +128,16 @@ describe('constants', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('AIHLP-031b: an own server that answered without a model list says so in reason (#2808)', () => {
|
||||
const err = providerRefusal(new AiProviderError('provider_unavailable', 404, true, undefined, 'no_model_list'));
|
||||
expect(err.getStatus()).toBe(502);
|
||||
expect(err.toBody()).toEqual({
|
||||
error: 'The server answered, but sent no model list',
|
||||
code: 'provider_unavailable',
|
||||
reason: 'no_model_list',
|
||||
});
|
||||
});
|
||||
|
||||
it('AIHLP-032: the host a conversation goes to', () => {
|
||||
expect(aiHostOf('anthropic', null)).toBe('api.anthropic.com');
|
||||
expect(aiHostOf('chatgpt', null)).toBe('api.openai.com');
|
||||
@@ -135,6 +146,16 @@ describe('constants', () => {
|
||||
expect(aiHostOf('custom', null)).toBe('');
|
||||
});
|
||||
|
||||
it('AIHLP-032b: a bare own server address chats under /v1, an address with a path as entered', () => {
|
||||
expect(ownServerChatBase('http://ollama.lan:11434')).toBe('http://ollama.lan:11434/v1');
|
||||
expect(ownServerChatBase(' http://ollama.lan:11434/ ')).toBe('http://ollama.lan:11434/v1');
|
||||
expect(ownServerChatBase('http://ollama.lan:11434/v1/')).toBe('http://ollama.lan:11434/v1');
|
||||
expect(ownServerChatBase('https://api.groq.com/openai/v1')).toBe('https://api.groq.com/openai/v1');
|
||||
expect(ownServerChatBase('https://llm.example.org/proxy')).toBe('https://llm.example.org/proxy');
|
||||
expect(ownServerChatBase('not a url')).toBe('not a url');
|
||||
expect(ownServerChatBase(null)).toBe('');
|
||||
});
|
||||
|
||||
it('AIHLP-033: OpenRouter sign-in is offered only on an https app URL', () => {
|
||||
vi.stubEnv('APP_URL', 'https://trip.example.org');
|
||||
expect(openrouterOAuthAvailable()).toBe(true);
|
||||
|
||||
@@ -201,6 +201,19 @@ describe('ModelCatalogService', () => {
|
||||
).rejects.toMatchObject({ code: 'provider_unavailable', status: 502 });
|
||||
});
|
||||
|
||||
it('MCAT-007b: a custom server that refuses the key, or answers without a list, says so (#2808)', async () => {
|
||||
const target = { kind: 'custom' as const, baseUrl: 'https://llm.example.org/v1', secret: 'k' };
|
||||
llmLocal.listModels.mockRejectedValueOnce(new DomainError(502, 'rejected', { code: 'llm_key_refused' }));
|
||||
await expect(catalog().list(target)).rejects.toMatchObject({ code: 'invalid_key', retryable: false });
|
||||
llmLocal.listModels.mockRejectedValueOnce(new DomainError(502, 'no list', { code: 'llm_no_model_list' }));
|
||||
await expect(catalog().list(target)).rejects.toMatchObject({
|
||||
code: 'provider_unavailable',
|
||||
providerCode: 'no_model_list',
|
||||
});
|
||||
llmLocal.listModels.mockRejectedValueOnce(new Error('boom'));
|
||||
await expect(catalog().list(target)).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
it('MCAT-008: the default model is a capable one, else the first listed', () => {
|
||||
const list = (...ids: string[]) => ids.map((id) => ({ id, label: id, supportsTools: true }));
|
||||
expect(pickDefaultModel('anthropic', list('claude-haiku', 'claude-sonnet-5', 'claude-opus'))).toBe(
|
||||
|
||||
@@ -28,6 +28,12 @@ import { toProviderSchema, type AiFetch } from '../../../../src/nest/ai-connecti
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { logWarn } = vi.hoisted(() => ({ logWarn: vi.fn() }));
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../../src/nest/audit/audit-log.logger')>()),
|
||||
logWarn,
|
||||
}));
|
||||
|
||||
/** An SSE body from whole events, cut every `every` characters so no event arrives in one piece. */
|
||||
function sseBody(events: unknown[], every = 7): ReadableStream<Uint8Array> {
|
||||
const text = events.map((e) => `data: ${typeof e === 'string' ? e : JSON.stringify(e)}\n\n`).join('');
|
||||
@@ -145,6 +151,40 @@ describe('provider errors', () => {
|
||||
expect(errorCodeOf(null)).toBeUndefined();
|
||||
expect(errorCodeOf({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('PERR-004: a refusal is logged with the path of the field it names, never with its message (#2845)', async () => {
|
||||
const refused = (param: unknown) =>
|
||||
failure(
|
||||
new OpenAiResponsesClient(
|
||||
'm',
|
||||
async () => 'at',
|
||||
fetchAnswering(
|
||||
jsonResponse(400, {
|
||||
error: {
|
||||
type: 'invalid_request_error',
|
||||
code: null,
|
||||
message: 'No tool output found for function call call_note. Dinner in Rome at 20:00.',
|
||||
param,
|
||||
},
|
||||
}),
|
||||
).fetcher,
|
||||
).stream(REQUEST, signal()),
|
||||
);
|
||||
|
||||
logWarn.mockClear();
|
||||
expect(await refused('input[3]')).toMatchObject({ code: 'generic', status: 400 });
|
||||
expect(logWarn).toHaveBeenCalledWith(
|
||||
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error) at input[3]',
|
||||
);
|
||||
|
||||
logWarn.mockClear();
|
||||
await refused('input[3] Dinner in Rome');
|
||||
await refused(null);
|
||||
expect(logWarn.mock.calls.map(([line]) => line)).toEqual([
|
||||
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error)',
|
||||
'[ai] chatgpt refused the request: HTTP 400 (invalid_request_error)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toProviderSchema', () => {
|
||||
@@ -489,6 +529,125 @@ describe('OpenAiChatClient', () => {
|
||||
await failure(client(streamResponse([{ choices: [{ delta: { content: 'half' } }] }])).stream(REQUEST, signal())),
|
||||
).toMatchObject({ providerCode: 'stream_cut' });
|
||||
});
|
||||
|
||||
// Gemini 3 signs the first call of a step on its first piece, the way its Chat Completions endpoint streams it.
|
||||
const SIGNED = [
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: 'function-call-1',
|
||||
type: 'function',
|
||||
function: { name: 'create_place', arguments: '{"name":"Nijo"}' },
|
||||
extra_content: { google: { thought_signature: 'sig-A' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [{ index: 1, function: { name: 'list_places', arguments: '{}' } }],
|
||||
},
|
||||
finish_reason: 'tool_calls',
|
||||
},
|
||||
],
|
||||
},
|
||||
'[DONE]',
|
||||
];
|
||||
|
||||
it('OACH-006: a signed call comes back as the provider state, keyed by the id it goes on with', async () => {
|
||||
const events = await run(
|
||||
new OpenAiChatClient(
|
||||
'gemini',
|
||||
'gemini-3-flash',
|
||||
'https://g',
|
||||
'k',
|
||||
fetchAnswering(streamResponse(SIGNED)).fetcher,
|
||||
).stream(REQUEST, signal()),
|
||||
);
|
||||
expect(events.filter((event) => event.type === 'tool-call')).toEqual([
|
||||
{ type: 'tool-call', call: { id: 'function-call-1', name: 'create_place', args: { name: 'Nijo' } } },
|
||||
{ type: 'tool-call', call: { id: 'call_1', name: 'list_places', args: {} } },
|
||||
]);
|
||||
expect(events.slice(-2)).toEqual([
|
||||
{
|
||||
type: 'provider-state',
|
||||
state: { adapter: 'openai-chat', model: 'gemini-3-flash', signatures: { 'function-call-1': 'sig-A' } },
|
||||
},
|
||||
{ type: 'finish', reason: 'tool-calls' },
|
||||
]);
|
||||
|
||||
// An unsigned turn keeps no state: nothing is stored for a provider that signs nothing.
|
||||
const plain = await run(
|
||||
new OpenAiChatClient('openai', 'm', 'https://x', 'k', fetchAnswering(streamResponse(STREAM)).fetcher).stream(
|
||||
REQUEST,
|
||||
signal(),
|
||||
),
|
||||
);
|
||||
expect(plain.some((event) => event.type === 'provider-state')).toBe(false);
|
||||
});
|
||||
|
||||
it('OACH-007: the signature goes back with its call to the model that signed it, and to no other', async () => {
|
||||
const signed: AiChatRequest = {
|
||||
...REQUEST,
|
||||
messages: [
|
||||
{ role: 'user', text: 'Add Nijo' },
|
||||
{
|
||||
role: 'assistant',
|
||||
text: '',
|
||||
toolCalls: [
|
||||
{ id: 'function-call-1', name: 'create_place', args: { name: 'Nijo' } },
|
||||
{ id: 'call_1', name: 'list_places', args: {} },
|
||||
],
|
||||
providerState: {
|
||||
adapter: 'openai-chat',
|
||||
model: 'gemini-3-flash',
|
||||
signatures: { 'function-call-1': 'sig-A' },
|
||||
},
|
||||
},
|
||||
{ role: 'tool', toolCallId: 'function-call-1', name: 'create_place', output: '{"id":7}' },
|
||||
{ role: 'tool', toolCallId: 'call_1', name: 'list_places', output: '[]' },
|
||||
],
|
||||
};
|
||||
const http = fetchAnswering(streamResponse(['[DONE]']));
|
||||
await run(
|
||||
new OpenAiChatClient('gemini', 'gemini-3-flash', 'https://g', 'k', http.fetcher).stream(signed, signal()),
|
||||
);
|
||||
expect(dig(http.body(), 'messages', 2, 'tool_calls')).toEqual([
|
||||
{
|
||||
id: 'function-call-1',
|
||||
type: 'function',
|
||||
function: { name: 'create_place', arguments: '{"name":"Nijo"}' },
|
||||
extra_content: { google: { thought_signature: 'sig-A' } },
|
||||
},
|
||||
{ id: 'call_1', type: 'function', function: { name: 'list_places', arguments: '{}' } },
|
||||
]);
|
||||
|
||||
// A thread that moved on to another model sends the calls plain: no other model reads the signature.
|
||||
for (const messages of [toChatMessages(signed, 'gpt-5-mini'), toChatMessages(signed)]) {
|
||||
expect(JSON.stringify(messages)).not.toContain('extra_content');
|
||||
}
|
||||
});
|
||||
|
||||
it('OACH-008: an own server on Gemini’s endpoint gets Gemini’s narrower schemas, any other the full ones', async () => {
|
||||
const parameters = async (baseUrl: string) => {
|
||||
const http = fetchAnswering(streamResponse(['[DONE]']));
|
||||
await run(new OpenAiChatClient('custom', 'm', baseUrl, 'k', http.fetcher).stream(REQUEST, signal()));
|
||||
return dig(http.body(), 'tools', 0, 'function', 'parameters');
|
||||
};
|
||||
expect(await parameters('https://generativelanguage.googleapis.com/v1beta/openai')).toEqual(
|
||||
toProviderSchema(TOOL.parameters, 'gemini'),
|
||||
);
|
||||
expect(await parameters('http://ollama:11434/v1')).toEqual(toProviderSchema(TOOL.parameters, 'custom'));
|
||||
expect(await parameters('not a url')).toEqual(toProviderSchema(TOOL.parameters, 'custom'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAiResponsesClient', () => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The bundled assets/airports.json itself, not a fixture.
|
||||
*
|
||||
* OurAirports marks some airports with daily flights as having no scheduled
|
||||
* service, and the build used to drop them on that flag alone (#2820, Little
|
||||
* Cayman). These pin a few of them and the codes saved bookings already point at.
|
||||
*/
|
||||
import { findByIata } from '../../../../src/nest/airports/airports.data';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('bundled airport dataset', () => {
|
||||
it('AIRPORT-ASSET-001: keeps airports OurAirports flags as unscheduled', () => {
|
||||
expect(findByIata('LYB')?.name).toBe('Edward Bodden Little Cayman Airfield');
|
||||
expect(findByIata('VEY')?.country).toBe('IS');
|
||||
});
|
||||
|
||||
it('AIRPORT-ASSET-002: still resolves the codes it carried before', () => {
|
||||
for (const code of ['PBI', 'ADA', 'GCM', 'CYB', 'FRA']) {
|
||||
expect(findByIata(code), code).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,10 @@
|
||||
*/
|
||||
import type { AssistantMessageRow } from '../../../../src/db/repositories/AssistantMessages.repository';
|
||||
import type { AssistantToolCallRow } from '../../../../src/db/repositories/AssistantToolCalls.repository';
|
||||
import type { AiChatMessage } from '../../../../src/nest/ai-connections/ai-chat-client.interface';
|
||||
import { toAnthropicMessages } from '../../../../src/nest/ai-connections/providers/anthropic-messages.client';
|
||||
import { toChatMessages } from '../../../../src/nest/ai-connections/providers/openai-chat.client';
|
||||
import { toResponsesInput } from '../../../../src/nest/ai-connections/providers/openai-responses.client';
|
||||
import {
|
||||
messageIdOf,
|
||||
outputText,
|
||||
@@ -249,7 +253,7 @@ describe('toChatHistory', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('ASTHIS-005d: the text of a file the user let the model read comes after the step, once, cut in earlier turns', () => {
|
||||
it('ASTHIS-005d: the text of a file the user let the model read answers its read, once, cut in earlier turns', () => {
|
||||
const id = '0b7c2f0e-6f6d-4a8e-9b1a-3d2c1e0f9a87';
|
||||
type CallPart = Extract<AssistantMessagePart, { type: 'tool-call' }>;
|
||||
const read = (toolCallId: string, extra: Partial<CallPart>): AssistantMessagePart => ({
|
||||
@@ -270,20 +274,27 @@ describe('toChatHistory', () => {
|
||||
row('user', [{ type: 'text', text: 'Thanks' }]),
|
||||
];
|
||||
const history = toChatHistory(rows, new Map(), new Map([[id, long]]));
|
||||
expect(history.map((message) => message.role)).toEqual(['user', 'assistant', 'user', 'user', 'user']);
|
||||
expect(history[1]).toMatchObject({ role: 'assistant', text: 'I need to read it first.', toolCalls: [] });
|
||||
const told = (history[2] as { text: string }).text;
|
||||
expect(history.map((message) => message.role)).toEqual(['user', 'assistant', 'tool', 'assistant', 'tool', 'user']);
|
||||
expect(history[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
text: 'I need to read it first.',
|
||||
toolCalls: [{ id: 'r1', name: 'read_attached_file' }],
|
||||
});
|
||||
expect(history[2]).toMatchObject({ role: 'tool', toolCallId: 'r1' });
|
||||
const told = (history[2] as { output: string }).output;
|
||||
expect(told).toContain('The user let you read the attached file "AirBnB Buchung.pdf".');
|
||||
expect(told).toContain('Casa Lisboa');
|
||||
// An earlier turn's text is cut so it does not crowd out the conversation.
|
||||
expect(told.length).toBeLessThan(long.length);
|
||||
expect(told.endsWith('[...]')).toBe(true);
|
||||
expect((history[3] as { text: string }).text).toBe(
|
||||
'You read the attached file "AirBnB Buchung.pdf" again; its text is above.',
|
||||
);
|
||||
expect(history[4]).toMatchObject({
|
||||
role: 'tool',
|
||||
toolCallId: 'r2',
|
||||
output: 'You read the attached file "AirBnB Buchung.pdf" again; its text is above.',
|
||||
});
|
||||
// The latest turn keeps the whole text.
|
||||
const latest = toChatHistory(rows.slice(0, 2), new Map(), new Map([[id, long]]));
|
||||
expect((latest[2] as { text: string }).text).toContain(long);
|
||||
expect((latest[2] as { output: string }).output).toContain(long);
|
||||
});
|
||||
|
||||
it('ASTHIS-005e: a read the user did not allow, one still waiting, a refused one and a swept file say so', () => {
|
||||
@@ -303,21 +314,21 @@ describe('toChatHistory', () => {
|
||||
]),
|
||||
],
|
||||
new Map(),
|
||||
).map((message) => (message as { text: string }).text);
|
||||
expect(step({ approval: { id: 'r', approved: false } })[1]).toBe(
|
||||
).map((message) => (message.role === 'tool' ? message.output : null));
|
||||
expect(step({ approval: { id: 'r', approved: false } })[2]).toBe(
|
||||
'The user did not let you read the attached file "a.pdf".',
|
||||
);
|
||||
expect(step({ approval: { id: 'r' } })[1]).toContain("waits for the user's agreement; you have not read it.");
|
||||
expect(step({ approval: { id: 'r', resolution: 'expired' } })[1]).toContain('you have not read it');
|
||||
expect(step({ result: 'No such attachment in this conversation.', isError: true })[1]).toBe(
|
||||
expect(step({ approval: { id: 'r' } })[2]).toContain("waits for the user's agreement; you have not read it.");
|
||||
expect(step({ approval: { id: 'r', resolution: 'expired' } })[2]).toContain('you have not read it');
|
||||
expect(step({ result: 'No such attachment in this conversation.', isError: true })[2]).toBe(
|
||||
'Reading the attached file "a.pdf" failed: No such attachment in this conversation.',
|
||||
);
|
||||
expect(step({ result: { read: true } })[1]).toBe(
|
||||
expect(step({ result: { read: true } })[2]).toBe(
|
||||
'The attached file "a.pdf" is no longer kept; you cannot read it again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('ASTHIS-005f: keeping an attached file is told after its step, whatever became of it', () => {
|
||||
it('ASTHIS-005f: keeping an attached file is answered with what became of it', () => {
|
||||
const keep = (extra: Partial<Extract<AssistantMessagePart, { type: 'tool-call' }>>) =>
|
||||
toChatHistory(
|
||||
[
|
||||
@@ -336,11 +347,12 @@ describe('toChatHistory', () => {
|
||||
new Map(),
|
||||
);
|
||||
const kept = keep({ approval: { id: 'k', approved: true }, result: { file: { id: 31 } } });
|
||||
expect(kept.map((message) => message.role)).toEqual(['user', 'assistant', 'user']);
|
||||
expect((kept[2] as { text: string }).text).toBe(
|
||||
'The attached file "a.pdf" is in the trip\'s Files now (file id 31).',
|
||||
);
|
||||
const lastOf = (extra: Parameters<typeof keep>[0]) => (keep(extra).at(-1) as { text: string }).text;
|
||||
expect(kept.map((message) => message.role)).toEqual(['user', 'assistant', 'tool']);
|
||||
expect(kept[2]).toMatchObject({
|
||||
toolCallId: 'k',
|
||||
output: 'The attached file "a.pdf" is in the trip\'s Files now (file id 31).',
|
||||
});
|
||||
const lastOf = (extra: Parameters<typeof keep>[0]) => (keep(extra).at(-1) as { output: string }).output;
|
||||
expect(
|
||||
lastOf({ approval: { id: 'k', approved: true }, result: 'The file could not be kept.', isError: true }),
|
||||
).toBe('The attached file "a.pdf" could not be kept in Files: The file could not be kept.');
|
||||
@@ -448,6 +460,168 @@ describe('toChatHistory', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('toChatHistory: calls the model made of the server tools (#2845)', () => {
|
||||
/**
|
||||
* The thread of #2845: a step that kept a trip note on the way (it runs at
|
||||
* once), then a step whose changes the user applied, both with the
|
||||
* provider's own record of the step stored beside them.
|
||||
*/
|
||||
function appliedAfterANote(
|
||||
noteState: Record<string, unknown>,
|
||||
changeState: Record<string, unknown>,
|
||||
): AiChatMessage[] {
|
||||
const rows = [
|
||||
row('user', [{ type: 'text', text: 'Dinner at 20:00 on day 2, and remember we like late dinners' }]),
|
||||
row(
|
||||
'assistant',
|
||||
[
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'call_note',
|
||||
toolName: 'remember_for_trip',
|
||||
args: { note: 'Likes late dinners' },
|
||||
result: { notes: 'Likes late dinners' },
|
||||
},
|
||||
],
|
||||
{ provider_state: JSON.stringify(noteState) },
|
||||
),
|
||||
row(
|
||||
'assistant',
|
||||
[
|
||||
{ type: 'text', text: 'Shall I move it?' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'held-1',
|
||||
toolName: 'update_assignment_time',
|
||||
args: { assignmentId: 7, place_time: '20:00' },
|
||||
result: { assignment: { id: 7 } },
|
||||
approval: { id: 'held-1', approved: true },
|
||||
},
|
||||
],
|
||||
{ provider_state: JSON.stringify(changeState) },
|
||||
),
|
||||
];
|
||||
return toChatHistory(rows, new Map([['held-1', 'call_move']]));
|
||||
}
|
||||
|
||||
it('ASTHIS-015: the Responses API gets an answer for every call it made, a kept note included', () => {
|
||||
const history = appliedAfterANote(
|
||||
{
|
||||
adapter: 'openai-responses',
|
||||
items: [
|
||||
{ type: 'reasoning', id: 'rs_1', encrypted_content: 'x' },
|
||||
{ type: 'function_call', call_id: 'call_note', name: 'remember_for_trip', arguments: '{}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
adapter: 'openai-responses',
|
||||
items: [
|
||||
{ type: 'reasoning', id: 'rs_2', encrypted_content: 'y' },
|
||||
{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Shall I move it?' }] },
|
||||
{ type: 'function_call', call_id: 'call_move', name: 'update_assignment_time', arguments: '{}' },
|
||||
],
|
||||
},
|
||||
);
|
||||
const input = toResponsesInput({ system: '', messages: history, tools: [] });
|
||||
const made = input.filter((item) => item.type === 'function_call').map((item) => item.call_id);
|
||||
const answered = input.filter((item) => item.type === 'function_call_output').map((item) => item.call_id);
|
||||
expect(made).toEqual(['call_note', 'call_move']);
|
||||
expect(answered).toEqual(['call_note', 'call_move']);
|
||||
for (const id of made) {
|
||||
const call = input.findIndex((item) => item.type === 'function_call' && item.call_id === id);
|
||||
const output = input.findIndex((item) => item.type === 'function_call_output' && item.call_id === id);
|
||||
expect(output).toBeGreaterThan(call);
|
||||
}
|
||||
expect(input.find((item) => item.type === 'function_call_output' && item.call_id === 'call_note')).toMatchObject({
|
||||
output: '{"notes":"Likes late dinners"}',
|
||||
});
|
||||
// Nothing empty goes out as the user's: a kept note is no step of the server to tell.
|
||||
const userTexts = input
|
||||
.filter((item) => item.type === 'message' && item.role === 'user')
|
||||
.flatMap((item) => (item.content as Array<{ text?: string }>).map((part) => part.text ?? ''));
|
||||
expect(userTexts).toEqual(['Dinner at 20:00 on day 2, and remember we like late dinners']);
|
||||
});
|
||||
|
||||
it('ASTHIS-016: Anthropic gets a tool_result for every tool_use it replays, a kept note included', () => {
|
||||
const history = appliedAfterANote(
|
||||
{
|
||||
adapter: 'anthropic-messages',
|
||||
blocks: [
|
||||
{ type: 'thinking', thinking: 'note it', signature: 's1' },
|
||||
{ type: 'tool_use', id: 'call_note', name: 'remember_for_trip', input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
adapter: 'anthropic-messages',
|
||||
blocks: [
|
||||
{ type: 'thinking', thinking: 'move it', signature: 's2' },
|
||||
{ type: 'text', text: 'Shall I move it?' },
|
||||
{ type: 'tool_use', id: 'call_move', name: 'update_assignment_time', input: {} },
|
||||
],
|
||||
},
|
||||
);
|
||||
const messages = toAnthropicMessages(history);
|
||||
const uses = messages.flatMap((message) => message.content).filter((block) => block.type === 'tool_use');
|
||||
expect(uses.map((block) => block.id)).toEqual(['call_note', 'call_move']);
|
||||
messages.forEach((message, index) => {
|
||||
const ids = message.content.filter((block) => block.type === 'tool_use').map((block) => block.id);
|
||||
if (ids.length === 0) return;
|
||||
const next = messages[index + 1];
|
||||
expect(next?.role).toBe('user');
|
||||
const results = next.content.filter((block) => block.type === 'tool_result').map((block) => block.tool_use_id);
|
||||
expect(results).toEqual(ids);
|
||||
});
|
||||
expect(messages.flatMap((message) => message.content).some((block) => block.text === '')).toBe(false);
|
||||
});
|
||||
|
||||
it('ASTHIS-018: Gemini gets every stored call back with its thought signature, an applied change included', () => {
|
||||
const state = (id: string, signature: string) => ({
|
||||
adapter: 'openai-chat',
|
||||
model: 'gemini-3-flash',
|
||||
signatures: { [id]: signature },
|
||||
});
|
||||
type SentCall = { id: string; extra_content?: { google: { thought_signature: string } } };
|
||||
const history = appliedAfterANote(state('call_note', 'sig-note'), state('call_move', 'sig-move'));
|
||||
const sent = toChatMessages({ system: '', messages: history, tools: [] }, 'gemini-3-flash')
|
||||
.flatMap((message) => (message.tool_calls ?? []) as SentCall[])
|
||||
.map((call) => [call.id, call.extra_content?.google.thought_signature]);
|
||||
expect(sent).toEqual([
|
||||
['call_note', 'sig-note'],
|
||||
['call_move', 'sig-move'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ASTHIS-017: a step that only kept a note is the model call it was, answered with what was kept', () => {
|
||||
const history = toChatHistory(
|
||||
[
|
||||
row('user', [{ type: 'text', text: 'Remember the ferry leaves at 7' }]),
|
||||
row('assistant', [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'n1',
|
||||
toolName: 'remember_for_trip',
|
||||
args: { note: 'Ferry at 7' },
|
||||
result: { notes: 'Ferry at 7' },
|
||||
},
|
||||
]),
|
||||
row('assistant', [{ type: 'text', text: 'Noted.' }]),
|
||||
],
|
||||
new Map(),
|
||||
);
|
||||
expect(history).toEqual([
|
||||
{ role: 'user', text: 'Remember the ferry leaves at 7' },
|
||||
{
|
||||
role: 'assistant',
|
||||
text: '',
|
||||
toolCalls: [{ id: 'n1', name: 'remember_for_trip', args: { note: 'Ferry at 7' } }],
|
||||
providerState: null,
|
||||
},
|
||||
{ role: 'tool', toolCallId: 'n1', name: 'remember_for_trip', output: '{"notes":"Ferry at 7"}' },
|
||||
{ role: 'assistant', text: 'Noted.', toolCalls: [], providerState: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toThreadMessages', () => {
|
||||
it('ASTHIS-010: the steps of one answer are one message under the first row id', () => {
|
||||
nextId = 10;
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('describeToolCall', () => {
|
||||
});
|
||||
expect(describe_('update_day_note', { dayId: 31, noteId: 41, time: null })).toMatchObject({
|
||||
subject: 'Rail pass',
|
||||
rows: [{ op: 'change', labelKey: 'time', before: '09:00', after: '' }],
|
||||
rows: [{ op: 'change', labelKey: 'content', before: '09:00', after: '' }],
|
||||
});
|
||||
expect(describe_('create_day_note', { dayId: 33, text: 'Laundry', icon: '🧺' }).rows).toEqual([
|
||||
{ op: 'add', labelKey: 'day', before: null, after: 'Spare day' },
|
||||
|
||||
@@ -247,7 +247,7 @@ describe('what the engine reads', () => {
|
||||
|
||||
expect(travel.entries.map((e) => [e.id, e.title, e.mood])).toEqual([[entry.id, 'Alfama', 'amazing']]);
|
||||
expect(travel.locale).toBe('de-DE');
|
||||
expect(travel.labels).toMatchObject({ day: 'TAG {day}', stations: 'Stationen', pros: 'Pro', cons: 'Contra' });
|
||||
expect(travel.labels).toMatchObject({ day: 'TAG {day}', stations: 'Unterwegs', pros: 'Pro', cons: 'Contra' });
|
||||
expect(travel.path).toEqual([]);
|
||||
expect((await edit.wordsFor(owner.id)).locale).toBe('de-DE');
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
bookPhotoElementSchema,
|
||||
bookShapeElementSchema,
|
||||
bookTextElementSchema,
|
||||
MAX_SPREADS,
|
||||
type BookDesignPlanRow,
|
||||
type BookDocument,
|
||||
type BookSpread,
|
||||
@@ -310,15 +309,15 @@ describe('applyDesignDraft', () => {
|
||||
expect(out.doc.spreads).toEqual(base.spreads);
|
||||
});
|
||||
|
||||
it('JBAPPLY-014: a book at the page limit takes no new page, the page named as changed since', () => {
|
||||
const inner = Array.from({ length: MAX_SPREADS - 1 }, (_, i) => page(`i${i}`, 'inner'));
|
||||
const full = book([page('c', 'cover'), ...inner]);
|
||||
const draft: DraftToApply = { base: full, doc: book(pages('n')), plan: [row('n', 'insert')], selection: ['n'] };
|
||||
it('JBAPPLY-014: a book of hundreds of pages still takes a new page', () => {
|
||||
const inner = Array.from({ length: 400 }, (_, i) => page(`i${i}`, 'inner'));
|
||||
const long = book([page('c', 'cover'), ...inner]);
|
||||
const draft: DraftToApply = { base: long, doc: book(pages('n')), plan: [row('n', 'insert')], selection: ['n'] };
|
||||
|
||||
const out = applyDesignDraft(full, draft, new Set());
|
||||
const out = applyDesignDraft(long, draft, new Set());
|
||||
|
||||
expect(out.skipped).toEqual([{ spreadId: 'n', reason: 'changed_since' }]);
|
||||
expect(out.doc.spreads).toHaveLength(MAX_SPREADS);
|
||||
expect([out.applied, out.skipped]).toEqual([['n'], []]);
|
||||
expect(out.doc.spreads).toHaveLength(402);
|
||||
});
|
||||
|
||||
it('JBAPPLY-015: a page whose letters are filled with a photo gone since stays out, as a frame of it would', () => {
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('the book words', () => {
|
||||
expect(french.locale).toBe('fr-FR');
|
||||
expect(french.labels).toMatchObject({
|
||||
day: 'JOUR {day}',
|
||||
stations: 'Étapes',
|
||||
stations: 'En chemin',
|
||||
summary: 'Résumé',
|
||||
countries: 'Pays',
|
||||
});
|
||||
@@ -30,7 +30,7 @@ describe('the book words', () => {
|
||||
locale: 'en-US',
|
||||
labels: {
|
||||
day: 'DAY {day}',
|
||||
stations: 'Stations',
|
||||
stations: 'Along the way',
|
||||
summary: 'Summary',
|
||||
countries: 'Countries',
|
||||
distance: 'Distance',
|
||||
|
||||
@@ -96,11 +96,63 @@ describe('LlmLocalService.listModels', () => {
|
||||
expect(fetchFn.mock.calls[1][1].headers).toMatchObject({ authorization: 'Bearer k' });
|
||||
});
|
||||
|
||||
it('keeps the 404 when the OpenAI model list is missing too', async () => {
|
||||
mockFetch(async () => ({ ok: false, status: 404, body: null }));
|
||||
it('says no model list came, with the first status, when no list exists anywhere', async () => {
|
||||
const fetchFn = mockFetch(async () => ({ ok: false, status: 404, body: null }));
|
||||
await expect(svc().listModels('http://nothing:1')).rejects.toMatchObject({
|
||||
response: { error: 'Local LLM server error (404)' },
|
||||
code: 'llm_no_model_list',
|
||||
response: { error: 'Local LLM server sent no model list (404)' },
|
||||
});
|
||||
expect(fetchFn.mock.calls.map((c: unknown[]) => c[0])).toEqual([
|
||||
'http://nothing:1/api/tags',
|
||||
'http://nothing:1/models',
|
||||
'http://nothing:1/v1/models',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads past an /api/tags that answers 200 without a model list, as LM Studio does (#2808)', async () => {
|
||||
mockFetch(async (url?: string) =>
|
||||
url?.endsWith('/api/tags')
|
||||
? { ok: true, status: 200, json: async () => ({ error: 'Unexpected endpoint or method. (GET /api/tags)' }) }
|
||||
: { ok: true, status: 200, json: async () => ({ data: [{ id: 'qwen3-8b' }] }) },
|
||||
);
|
||||
expect((await svc().listModels('http://lmstudio:1234/v1')).models).toEqual([{ name: 'qwen3-8b', size: 0 }]);
|
||||
});
|
||||
|
||||
it('asks the address as entered when it does not end in /v1 (#2808)', async () => {
|
||||
const fetchFn = mockFetch(async (url?: string) =>
|
||||
url === 'https://owui.example.org/api/models'
|
||||
? { ok: true, status: 200, json: async () => ({ data: [{ id: 'llama3.3' }] }) }
|
||||
: { ok: false, status: 404, body: null },
|
||||
);
|
||||
expect((await svc().listModels('https://owui.example.org/api/', 'k')).models).toEqual([
|
||||
{ name: 'llama3.3', size: 0 },
|
||||
]);
|
||||
expect(fetchFn.mock.calls.map((c: unknown[]) => c[0])).toEqual([
|
||||
'https://owui.example.org/api/api/tags',
|
||||
'https://owui.example.org/api/models',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a refused key even when the refusal came from a later list', async () => {
|
||||
mockFetch(async (url?: string) =>
|
||||
url?.endsWith('/api/tags') ? { ok: false, status: 404, body: null } : { ok: false, status: 401, body: null },
|
||||
);
|
||||
await expect(svc().listModels('https://api.groq.com/openai/v1', 'bad')).rejects.toMatchObject({
|
||||
code: 'llm_key_refused',
|
||||
response: { error: 'Local LLM server rejected the API key (401)' },
|
||||
});
|
||||
});
|
||||
|
||||
it('skips a later list that cannot be reached, and a body that is not JSON', async () => {
|
||||
safeFetchLlmMock
|
||||
.mockImplementationOnce(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => Promise.reject(new Error('html')),
|
||||
}))
|
||||
.mockImplementationOnce(() => Promise.reject(new Error('ECONNRESET')))
|
||||
.mockImplementationOnce(async () => ({ ok: true, status: 200, json: async () => ({ data: [{ id: 'm' }] }) }));
|
||||
expect((await svc().listModels('http://vllm:8000')).models).toEqual([{ name: 'm', size: 0 }]);
|
||||
});
|
||||
|
||||
it('502s when the server is unreachable', async () => {
|
||||
|
||||
@@ -95,4 +95,79 @@ describe('extractEnforced', () => {
|
||||
mockFetch(() => jsonResponse({ error: 'model not found' }, false, 404));
|
||||
await expect(extractEnforced(INPUT)).rejects.toThrow(/Ollama \/api\/chat failed \(404\)/);
|
||||
});
|
||||
|
||||
it('throws for a server error without trying another path', async () => {
|
||||
const fetchFn = mockFetch(() => jsonResponse({ error: 'boom' }, false, 500));
|
||||
await expect(extractEnforced(INPUT)).rejects.toThrow(/failed \(500\)/);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractEnforced on an OpenAI-compatible server (#2806)', () => {
|
||||
const GROQ = { ...INPUT, baseUrl: 'https://api.groq.com/openai/v1', model: 'openai/gpt-oss-120b', apiKey: 'gsk' };
|
||||
const chatAnswer = (content: string) => jsonResponse({ choices: [{ message: { content } }] });
|
||||
const unknownUrl = (url: string) =>
|
||||
jsonResponse(
|
||||
{ error: { message: `Unknown request URL: POST ${new URL(url).pathname}.`, code: 'unknown_url' } },
|
||||
false,
|
||||
404,
|
||||
);
|
||||
|
||||
it('sends the extraction to /chat/completions at the address as entered when /api/chat is missing', async () => {
|
||||
const fetchFn = mockFetch((url = '') =>
|
||||
url.endsWith('/api/chat') ? unknownUrl(url) : chatAnswer('{"result":[{"name":"Hotel"}]}'),
|
||||
);
|
||||
expect(await extractEnforced(GROQ)).toEqual({ name: 'Hotel' });
|
||||
expect(fetchFn.mock.calls.map((c) => c[0])).toEqual([
|
||||
'https://api.groq.com/openai/api/chat',
|
||||
'https://api.groq.com/openai/v1/chat/completions',
|
||||
]);
|
||||
const init = fetchFn.mock.calls[1][1] as RequestInit;
|
||||
expect((init.headers as Record<string, string>).authorization).toBe('Bearer gsk');
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.model).toBe('openai/gpt-oss-120b');
|
||||
expect(body.response_format.json_schema.schema).toEqual({
|
||||
type: 'object',
|
||||
properties: { result: { type: 'array', items: { type: 'object' } } },
|
||||
required: ['result'],
|
||||
});
|
||||
expect(body.messages[0].content).toContain('{"result": [object]}');
|
||||
expect(body.messages[1].content[0].text).toBe('doc');
|
||||
});
|
||||
|
||||
it('falls back on a 405 or 501 too, and on a 200 that is not an Ollama answer (LM Studio)', async () => {
|
||||
const fallsBackAfter = async (first: Response) => {
|
||||
mockFetch((url = '') => (url.endsWith('/api/chat') ? first : chatAnswer('{"result":[{"a":1}]}')));
|
||||
expect(await extractEnforced(GROQ)).toEqual({ a: 1 });
|
||||
};
|
||||
await fallsBackAfter(jsonResponse({}, false, 405));
|
||||
await fallsBackAfter(jsonResponse({}, false, 501));
|
||||
await fallsBackAfter(jsonResponse({ error: 'Unexpected endpoint' }));
|
||||
});
|
||||
|
||||
it('answers null when the server returns no object', async () => {
|
||||
mockFetch((url = '') => (url.endsWith('/api/chat') ? unknownUrl(url) : chatAnswer('{"result":[]}')));
|
||||
expect(await extractEnforced(GROQ)).toBeNull();
|
||||
});
|
||||
|
||||
it('passes the images on as image parts with their type', async () => {
|
||||
const fetchFn = mockFetch((url = '') =>
|
||||
url.endsWith('/api/chat') ? unknownUrl(url) : chatAnswer('{"result":[{}]}'),
|
||||
);
|
||||
await extractEnforced({ ...GROQ, images: ['iVBORw0K', '/9j/4AAQ', 'UklGRiQA', 'R0lGODlh'] });
|
||||
const parts = JSON.parse((fetchFn.mock.calls[1][1] as RequestInit).body as string).messages[1].content;
|
||||
expect(parts.slice(1).map((p: { image_url: { url: string } }) => p.image_url.url.split(';')[0])).toEqual([
|
||||
'data:image/png',
|
||||
'data:image/jpeg',
|
||||
'data:image/webp',
|
||||
'data:image/gif',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports the OpenAI-compatible failure when that path fails too', async () => {
|
||||
mockFetch((url = '') =>
|
||||
url.endsWith('/api/chat') ? unknownUrl(url) : jsonResponse({ error: 'bad model' }, false, 404),
|
||||
);
|
||||
await expect(extractEnforced(GROQ)).rejects.toThrow(/LLM request failed \(404\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import {
|
||||
COORD_DEDUP_TOLERANCE,
|
||||
dedupSetOf,
|
||||
externalIdsOf,
|
||||
isPlaceDuplicate,
|
||||
mapWithConcurrency,
|
||||
@@ -25,6 +26,61 @@ vi.mock('../../../src/db/database', () => ({
|
||||
const emptyDedup = (): DedupSet => ({ names: new Set(), coords: [], externalIds: new Set() });
|
||||
|
||||
describe('isPlaceDuplicate / trackInsertedInDedupSet', () => {
|
||||
it('matches a track only against the tracks of that name, never a sight of that name (#2810)', () => {
|
||||
// The watch named the hike after the gorge, and the gorge is already a place of the trip.
|
||||
const dedup = dedupSetOf([
|
||||
{ name: 'Hokitika Gorge', lat: -42.95, lng: 171.0, route_geometry: null },
|
||||
{ name: ' 008 ', lat: 1, lng: 2, route_geometry: '[[1,2],[3,4]]' },
|
||||
]);
|
||||
expect(isPlaceDuplicate({ name: 'hokitika gorge', lat: -42.9, lng: 171.0, track: true }, dedup)).toBe(false);
|
||||
expect(isPlaceDuplicate({ name: '008', lat: 1, lng: 2, track: true, routeGeometry: '[[1,2],[3,4]]' }, dedup)).toBe(
|
||||
true,
|
||||
);
|
||||
// A pin still matches every place of its name, a track included.
|
||||
expect(isPlaceDuplicate({ name: 'Hokitika Gorge', lat: null, lng: null }, dedup)).toBe(true);
|
||||
expect(isPlaceDuplicate({ name: '008', lat: null, lng: null }, dedup)).toBe(true);
|
||||
|
||||
// A track imported earlier in the same file counts for the next one.
|
||||
trackInsertedInDedupSet({ name: 'Hokitika Gorge', lat: -42.9, lng: 171.0, track: true }, dedup);
|
||||
expect(isPlaceDuplicate({ name: 'Hokitika gorge ', lat: null, lng: null, track: true }, dedup)).toBe(true);
|
||||
// A set built by hand without tracks matches no track by name.
|
||||
const bare = emptyDedup();
|
||||
trackInsertedInDedupSet({ name: 'Ridge', lat: 1, lng: 2 }, bare);
|
||||
expect(isPlaceDuplicate({ name: 'Ridge', lat: 1, lng: 2, track: true }, bare)).toBe(false);
|
||||
trackInsertedInDedupSet({ name: 'Lake', lat: 1, lng: 2, track: true }, bare);
|
||||
expect([...(bare.tracks ?? [])]).toEqual(['lake\u0000']);
|
||||
});
|
||||
|
||||
it('keeps two tracks of one name apart when they run differently (#2870)', () => {
|
||||
// Two watch hikes, both called "Afternoon Hike": one up the valley, one along the coast.
|
||||
const valley = '[[46.5,8.0,1200],[46.52,8.03,1500],[46.55,8.06,1900]]';
|
||||
const coast = '[[43.7,7.25],[43.72,7.3],[43.74,7.35]]';
|
||||
const dedup = dedupSetOf([{ name: 'Afternoon Hike', lat: 46.5, lng: 8.0, route_geometry: valley }]);
|
||||
expect(
|
||||
isPlaceDuplicate({ name: 'afternoon hike', lat: 43.7, lng: 7.25, track: true, routeGeometry: coast }, dedup),
|
||||
).toBe(false);
|
||||
// The same file again is still the same track, its points rounded or thinned as they may be.
|
||||
const valleyAgain = '[[46.50001,8.00002],[46.55,8.06]]';
|
||||
expect(
|
||||
isPlaceDuplicate({ name: 'Afternoon Hike', lat: 46.5, lng: 8.0, track: true, routeGeometry: valleyAgain }, dedup),
|
||||
).toBe(true);
|
||||
|
||||
// Within one file: the first of the two goes in, the second is not taken for it.
|
||||
const batch = emptyDedup();
|
||||
const first = { name: 'Day 1', lat: 46.5, lng: 8.0, track: true, routeGeometry: valley };
|
||||
const second = { name: 'Day 1', lat: 43.7, lng: 7.25, track: true, routeGeometry: coast };
|
||||
expect(isPlaceDuplicate(first, batch)).toBe(false);
|
||||
trackInsertedInDedupSet(first, batch);
|
||||
expect(isPlaceDuplicate(second, batch)).toBe(false);
|
||||
trackInsertedInDedupSet(second, batch);
|
||||
expect(isPlaceDuplicate({ ...second }, batch)).toBe(true);
|
||||
|
||||
// A line that does not parse falls back to the name alone, on both sides.
|
||||
const broken = dedupSetOf([{ name: 'Loop', lat: 1, lng: 2, route_geometry: 'not json' }]);
|
||||
expect(isPlaceDuplicate({ name: 'Loop', lat: 1, lng: 2, track: true, routeGeometry: '{}' }, broken)).toBe(true);
|
||||
expect(isPlaceDuplicate({ name: 'Loop', lat: 1, lng: 2, track: true, routeGeometry: coast }, broken)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches a named place case- and whitespace-insensitively', () => {
|
||||
const dedup = emptyDedup();
|
||||
trackInsertedInDedupSet({ name: ' Eiffel Tower ', lat: 1, lng: 2 }, dedup);
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
* line is written as GPX.
|
||||
*/
|
||||
import { PlaceImportService } from '../../../src/nest/place-import/place-import.service';
|
||||
import { PlacesService } from '../../../src/nest/places/places.service';
|
||||
import { TourFileReadService } from '../../../src/nest/tours/tour-file-read.service';
|
||||
import { gpxOfLine, splitGpxTracks } from '../../../src/nest/tours/tour-file-split.helpers';
|
||||
import { TourFilesModule } from '../../../src/nest/tours/tour-files.module';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
@@ -18,7 +20,7 @@ import { TOUR_GPX_TEXT_MAX } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
// TOUR-FILE-READ-001 to TOUR-FILE-READ-008
|
||||
// TOUR-FILE-READ-001 to TOUR-FILE-READ-009
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
let t: TestModule;
|
||||
@@ -130,6 +132,25 @@ describe('TourFileReadService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('TOUR-FILE-READ-009: a track from before Tours under the same name is proposed, for the import to take over (#2810)', async () => {
|
||||
const trip = await tripId();
|
||||
// 4.x stored a GPX track as a plain place: a line, no tours row.
|
||||
await makePlace(t.get(MikroORM), trip, {
|
||||
name: 'Saturday',
|
||||
lat: 47.5,
|
||||
lng: 11.1,
|
||||
route_geometry: '[[47.5,11.1,800],[47.51,11.1,1000]]',
|
||||
});
|
||||
const rows = t.get(PlaceImportService).readGpx(Buffer.from(TWO_TRACKS, 'utf8'), { importTracks: true });
|
||||
expect((await t.get(PlacesService).gpxDuplicates(String(trip), rows)).some(Boolean)).toBe(true);
|
||||
|
||||
const read = await files.read(trip, Buffer.from(TWO_TRACKS, 'utf8'), 'weekend.gpx');
|
||||
expect(read.tracks.map((track) => [track.name, track.duplicate])).toEqual([
|
||||
['Saturday', false],
|
||||
['Sunday', false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('TOUR-FILE-READ-008: a track longer than one import takes is named but carries no text', async () => {
|
||||
const points = Array.from(
|
||||
{ length: 20_000 },
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* tracksToAdopt (#2810): which rows of a tour import take over a track that a
|
||||
* GPX import before Tours left as a plain place.
|
||||
*/
|
||||
import { tracksToAdopt } from '../../../src/nest/tours/tour-track-adoption';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const LINE = '[[46.1,11.1,1200],[46.2,11.2,1300]]';
|
||||
const track = (
|
||||
id: number,
|
||||
name: string,
|
||||
extra: { route_geometry?: string | null; tour_place_id?: number | null } = {},
|
||||
) => ({
|
||||
id,
|
||||
name,
|
||||
route_geometry: LINE,
|
||||
tour_place_id: null,
|
||||
...extra,
|
||||
});
|
||||
const rows = (...names: string[]) => names.map((name) => ({ name }));
|
||||
|
||||
describe('tracksToAdopt', () => {
|
||||
it('TRACK-ADOPT-001: pairs a row with a track of the same name, trimmed and in any case', () => {
|
||||
expect([...tracksToAdopt(rows('GARMIN HIKE'), [track(4, ' Garmin hike ')])]).toEqual([
|
||||
[0, { id: 4, route_geometry: LINE }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('TRACK-ADOPT-002: gives each track to one row only, oldest first', () => {
|
||||
const adopt = tracksToAdopt(rows('Hike', 'hike', 'HIKE'), [track(9, 'hike'), track(3, 'Hike')]);
|
||||
expect([...adopt]).toEqual([
|
||||
[0, { id: 3, route_geometry: LINE }],
|
||||
[1, { id: 9, route_geometry: LINE }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('TRACK-ADOPT-003: leaves out a tour, a place without a line, an unnamed place and an unnamed row', () => {
|
||||
const places = [
|
||||
track(1, 'Ridge', { tour_place_id: 1 }),
|
||||
track(2, 'Cafe', { route_geometry: null }),
|
||||
track(3, ' '),
|
||||
track(4, 'Lake'),
|
||||
];
|
||||
expect([...tracksToAdopt(rows('Ridge', 'Cafe', '', 'Other'), places)]).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -27,8 +27,8 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||||
import { ToursService } from '../../../src/nest/tours/tours.service';
|
||||
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
||||
import { accommodationsOver } from '../../helpers/accommodations-service';
|
||||
import { createTrip, createUser } from '../../helpers/factories';
|
||||
import { countRows, findRows } from '../../helpers/factories/rows';
|
||||
import { createPlace, createTrip, createUser } from '../../helpers/factories';
|
||||
import { countRows, findRows, updateRows } from '../../helpers/factories/rows';
|
||||
import { noPaddleChecks } from '../../helpers/fake-paddle-checks';
|
||||
import { tourStageDays } from '../../helpers/fake-tour-stage-days';
|
||||
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
||||
@@ -86,7 +86,7 @@ const mixedGpx = Buffer.from(`<gpx>
|
||||
</trkseg><trkseg><trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt></trkseg></trk>
|
||||
</gpx>`);
|
||||
|
||||
const TRIGGERS = ['fail_facet', 'fail_place', 'fail_color'];
|
||||
const TRIGGERS = ['fail_facet', 'fail_place', 'fail_color', 'fail_adopt'];
|
||||
|
||||
/** The collaborator set the container hands PlacesService (the places.service.test.ts builder). */
|
||||
async function makePlacesService(): Promise<PlacesService> {
|
||||
@@ -314,6 +314,21 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
await expectEmpty();
|
||||
});
|
||||
|
||||
it('rolls the new places back too when taking over a track from before Tours fails (#2810)', async () => {
|
||||
const legacy = createPlace(testDb, Number(tripId), { name: 'Ridge' }).id;
|
||||
await updateRows(
|
||||
await sharedTestOrm(testDb),
|
||||
Places,
|
||||
{ id: legacy },
|
||||
{ route_geometry: '[[49,12,100],[49.01,12.01,150]]' },
|
||||
);
|
||||
testDb.exec(`CREATE TRIGGER fail_adopt BEFORE INSERT ON tours WHEN NEW.place_id = ${legacy}
|
||||
BEGIN SELECT RAISE(ABORT, 'adopt failure'); END;`);
|
||||
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('adopt failure');
|
||||
expect(await Promise.all([count('places'), count('tours')])).toEqual([1, 0]);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back carriers and earlier colors when later coloring fails', async () => {
|
||||
testDb.exec(`CREATE TRIGGER fail_color BEFORE UPDATE OF route_color ON places WHEN NEW.name = 'Ridge'
|
||||
BEGIN SELECT RAISE(ABORT, 'color failure'); END;`);
|
||||
@@ -403,9 +418,10 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
});
|
||||
|
||||
it('imports only the new tracks and publishes only committed rows in a mixed result', async () => {
|
||||
// The same Ridge as in the mixed file, line and all: a track is a duplicate only of one that runs alike (#2870).
|
||||
const existing = Buffer.from(`<gpx><trk><name>Ridge</name><trkseg>
|
||||
<trkpt lat="49" lon="12"><ele>100</ele></trkpt><trkpt lat="49.01" lon="12.01"><ele>150</ele></trkpt>
|
||||
</trkseg></trk></gpx>`);
|
||||
</trkseg><trkseg><trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt></trkseg></trk></gpx>`);
|
||||
expect((await tours.importGpxAsTour(tripId, existing))?.tours).toHaveLength(1);
|
||||
broadcast.mockClear();
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ describe('buildTransitReservationParts', () => {
|
||||
],
|
||||
};
|
||||
|
||||
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary);
|
||||
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary, 'transitous');
|
||||
|
||||
expect(endpoints).toEqual([
|
||||
{
|
||||
@@ -407,7 +407,7 @@ describe('buildTransitReservationParts', () => {
|
||||
const kyoto = { name: 'Kyoto', lat: 35.0116, lng: 135.7681 };
|
||||
const itinerary = twoLegs();
|
||||
itinerary.legs = [{ ...itinerary.legs[0], to: stop('Umeda', 34.702, 135.496, null) }, itinerary.legs[1]];
|
||||
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary);
|
||||
const { endpoints, metadata } = buildTransitReservationParts(namba, kyoto, itinerary, 'transitous');
|
||||
expect(endpoints[1]).toEqual({
|
||||
role: 'stop',
|
||||
sequence: 1,
|
||||
@@ -423,14 +423,18 @@ describe('buildTransitReservationParts', () => {
|
||||
});
|
||||
|
||||
it('TRANSIT-ITIN-020: an unresolvable endpoint timezone throws with the coordinates in the message', () => {
|
||||
expect(() => buildTransitReservationParts({ name: 'Nowhere', lat: NaN, lng: 135.501 }, umeda, singleLeg())).toThrow(
|
||||
'Unable to resolve timezone for NaN,135.501.',
|
||||
);
|
||||
expect(() =>
|
||||
buildTransitReservationParts({ name: 'Nowhere', lat: NaN, lng: 135.501 }, umeda, singleLeg(), 'transitous'),
|
||||
).toThrow('Unable to resolve timezone for NaN,135.501.');
|
||||
});
|
||||
|
||||
it('TRANSIT-ITIN-021: an unconvertible itinerary time throws with the ISO string and timezone', () => {
|
||||
expect(() => buildTransitReservationParts(namba, umeda, singleLeg({ startTime: 'not-a-date' }))).toThrow(
|
||||
'Unable to convert not-a-date to local time in Asia/Tokyo.',
|
||||
);
|
||||
expect(() =>
|
||||
buildTransitReservationParts(namba, umeda, singleLeg({ startTime: 'not-a-date' }), 'transitous'),
|
||||
).toThrow('Unable to convert not-a-date to local time in Asia/Tokyo.');
|
||||
});
|
||||
|
||||
it('TRANSIT-ITIN-022: the journey records the backend its itinerary came from (#2850)', () => {
|
||||
expect(buildTransitReservationParts(namba, umeda, singleLeg(), 'google').metadata.transit.provider).toBe('google');
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/shared",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -154,6 +154,9 @@ export const ASSISTANT_CHANGE_LABEL_KEYS = [
|
||||
'currency',
|
||||
'stopType',
|
||||
'text',
|
||||
// A day note's heading and its body; `text` and `time` stay for rows already stored.
|
||||
'title',
|
||||
'content',
|
||||
'icon',
|
||||
'color',
|
||||
'position',
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
bookPageOpSchema,
|
||||
bookSpreadIdSchema,
|
||||
} from '../../book/book-ops.schema';
|
||||
import { bookSpreadSchema, MAX_BOOK_TITLE, MAX_SPREADS } from '../../book/book.schema';
|
||||
import { bookSpreadSchema, MAX_BOOK_TITLE } from '../../book/book.schema';
|
||||
import { idSchema } from '../../common/primitives.schema';
|
||||
import { cardCountSchema, cardText } from './card-parts';
|
||||
|
||||
@@ -33,14 +33,10 @@ export const BOOK_PAGE_CARD_CHANGES = ['new', 'changed', 'moved', 'willDelete']
|
||||
export const bookCardPageSchema = z.object({
|
||||
spreadId: bookSpreadIdSchema,
|
||||
/** Where it stands in the book, the cover at 0. */
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(MAX_SPREADS - 1),
|
||||
index: z.number().int().min(0),
|
||||
role: bookSpreadSchema.shape.role.unwrap(),
|
||||
/** Its place among the double pages, from 1; null for a single page (the covers, the first and the last). */
|
||||
number: z.number().int().min(1).max(MAX_SPREADS).nullable(),
|
||||
number: z.number().int().min(1).nullable(),
|
||||
/** Photos placed on it. */
|
||||
photos: cardCountSchema,
|
||||
});
|
||||
@@ -85,7 +81,7 @@ export const bookPageCardSchema = z.object({
|
||||
/** How many pages the change adds or deletes; for a new book, its pages. */
|
||||
count: cardCountSchema,
|
||||
/** Where a moved page lands among the double pages, from 1. */
|
||||
movedTo: z.number().int().min(1).max(MAX_SPREADS).nullable(),
|
||||
movedTo: z.number().int().min(1).nullable(),
|
||||
/**
|
||||
* `edit_book_page`'s operations, to draw the page after where the card has
|
||||
* no `after` or the Studio's book moved on from `baseVersion`; empty for
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { bookElementId } from '../book-ids';
|
||||
import { blankBook, blankSpread } from '../book-pages';
|
||||
import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
|
||||
import { MAX_BOOK_TITLE, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
|
||||
import type { JourneyStats } from '../journey-stats.schema';
|
||||
import {
|
||||
STATIONS_PER_PAGE,
|
||||
@@ -155,7 +155,7 @@ export function buildBook(input: AutoInput): BookDocument {
|
||||
version: 1,
|
||||
title: input.title.slice(0, MAX_BOOK_TITLE),
|
||||
page: withFirstPage(input.page),
|
||||
spreads: spreads.slice(0, MAX_SPREADS),
|
||||
spreads,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user