diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 8a1630424..9ba077f3f 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -1688,8 +1688,8 @@ function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick t: T; locale: string; onPick: (version: string) => void; onClose: () => void }) { return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>

{t('admin.plugins.versionPickerTitle', { name: plugin.name })}

diff --git a/client/src/components/Admin/DevNotificationsPanel.test.tsx b/client/src/components/Admin/DevNotificationsPanel.test.tsx index 2499bf595..1e71becb5 100644 --- a/client/src/components/Admin/DevNotificationsPanel.test.tsx +++ b/client/src/components/Admin/DevNotificationsPanel.test.tsx @@ -101,8 +101,8 @@ describe('DevNotificationsPanel', () => { render(<>); await screen.findByText('Type Testing'); - // Fire the click but do not await — handler never resolves so sending stays true - void user.click(screen.getByText('Simple → Me').closest('button')!); + // The request handler never resolves, so sending stays true after the click settles + await user.click(screen.getByText('Simple → Me').closest('button')!); await waitFor(() => { const buttons = screen.getAllByRole('button'); diff --git a/client/src/components/Journey/stripMarkdown.ts b/client/src/components/Journey/stripMarkdown.ts index 1667f005a..5cb9462a8 100644 --- a/client/src/components/Journey/stripMarkdown.ts +++ b/client/src/components/Journey/stripMarkdown.ts @@ -3,12 +3,13 @@ * Handles: bold, italic, headings, links, images, blockquotes, code, lists, hr. */ export function stripMarkdown(md: string): string { - return md + const withoutImages = md .replace(/^#{1,6}\s+/gm, '') // headings // the alt text is a run that cannot contain "](", so it ends on the first one // instead of being retried against every later "](". Same matches, no backtracking .replace(/!\[(?:(?!\]\().)*\]\(.*?\)/g, '') // images - .replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links → text + + return stripLinks(withoutImages) // links → text .replace(/(`{3}[\s\S]*?`{3})/g, '') // code blocks .replace(/`([^`]+)`/g, '$1') // inline code .replace(/\*\*(.+?)\*\*/g, '$1') // bold ** @@ -24,3 +25,68 @@ export function stripMarkdown(md: string): string { .replace(/\n/g, ' ') // remaining newlines → spaces .trim() } + +/** + * `.` matches every character except a line terminator, which is exactly the set a link + * target could be built from back when this was `.*?`. Testing a single character against + * it spells that out without hand-listing \n, \r and the two Unicode separators. + */ +const notALineTerminator = /./ + +/** + * Replace `[label](target)` with its label, exactly as `/\[([^\]]*)\]\(.*?\)/g` did. + * + * Written as a scan rather than a regex because that pattern backtracks: `[^\]]` matches + * `[` too, so on a run of `[` with no `]` after it the engine retries the whole + * label-and-target scan from every one of them and reads to the end each time. Measured + * on 32k of `[x`: 300ms, quadrupling with every doubling. The subject is a journal + * entry's story text, which any contributor to the journal can write and every reader's + * browser then renders a preview of. + * + * The semantics are the regex's, quirks included: + * - the label runs to the FIRST `]` and may itself contain `[`, so `[a[b](c)` keeps + * `a[b` — narrowing the label to `[^[\]]` would have been a behaviour change; + * - the label may be empty, since `[^\]]*` is zero-or-more, so `[](c)` is a link; + * - `(` has to sit directly after that `]`; + * - the target ends at the first `)`, and `.` cannot cross a line terminator, so a `(` + * whose first `)` only turns up on a later line is not a link at all. + */ +function stripLinks(input: string): string { + let out = '' + let cursor = 0 // everything before this has already been copied into `out` + let search = 0 // where the next `[` is looked for + // First `)`-or-line-terminator at or after the target we last examined. Targets are + // examined left to right, so this only ever moves forward — which is what stops a + // document full of `[a](` from being re-read once per candidate. + let stop = 0 + + for (;;) { + const open = input.indexOf('[', search) + if (open === -1) break + + const close = input.indexOf(']', open + 1) + // No `]` left anywhere: neither this `[` nor any after it can match. Rediscovering + // that from every `[` in turn is what the regex spent quadratic time on. + if (close === -1) break + + // Every `[` between `open` and `close` has that same first `]`, so the verdict on + // what follows it is the verdict for all of them — skip the lot when it fails. + if (input[close + 1] !== '(') { + search = close + 1 + continue + } + + if (stop < close + 2) stop = close + 2 + while (stop < input.length && input[stop] !== ')' && notALineTerminator.test(input[stop])) stop++ + if (input[stop] !== ')') { + search = close + 1 + continue + } + + out += input.slice(cursor, open) + input.slice(open + 1, close) + cursor = stop + 1 + search = stop + 1 + } + + return out + input.slice(cursor) +} diff --git a/client/src/components/Map/ReservationOverlay.tsx b/client/src/components/Map/ReservationOverlay.tsx index aba5513e3..79ea1570c 100644 --- a/client/src/components/Map/ReservationOverlay.tsx +++ b/client/src/components/Map/ReservationOverlay.tsx @@ -64,8 +64,25 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon { function toRad(d: number) { return d * Math.PI / 180 } +// "Wien Hbf (Vienna)" → "Wien Hbf". Done as a scan, not /\s*\([^)]*\)/g: the leading +// \s* overlaps the engine's own restart-at-every-position scan, so a name that is a +// long run of spaces with no "(" after it backtracks quadratically (1.0s at 32k), and +// so does a run of "(" with no ")". Same matches: the whitespace directly before a "(" +// that has a ")" after it goes too, and an unclosed "(" is left alone. function cleanName(name: string): string { - return name.replace(/\s*\([^)]*\)/g, '').trim() + let out = '' + let cursor = 0 + for (;;) { + const open = name.indexOf('(', cursor) + if (open === -1) break + const close = name.indexOf(')', open + 1) + if (close === -1) break + let start = open + while (start > cursor && /\s/.test(name[start - 1])) start-- + out += name.slice(cursor, start) + cursor = close + 1 + } + return (out + name.slice(cursor)).trim() } function haversineKm(a: [number, number], b: [number, number]): number { diff --git a/client/src/components/Map/reservationsMapbox.ts b/client/src/components/Map/reservationsMapbox.ts index 58d89ad7f..77bb48c3b 100644 --- a/client/src/components/Map/reservationsMapbox.ts +++ b/client/src/components/Map/reservationsMapbox.ts @@ -94,7 +94,26 @@ function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fal return h > 0 ? `${h}h ${m}m` : `${m}m` } -const cleanName = (name: string) => name.replace(/\s*\([^)]*\)/g, '').trim() +// "Wien Hbf (Vienna)" → "Wien Hbf". Done as a scan, not /\s*\([^)]*\)/g: the leading +// \s* overlaps the engine's own restart-at-every-position scan, so a name that is a +// long run of spaces with no "(" after it backtracks quadratically (1.2s at 40k). +// Same matches: the whitespace directly before a "(" that has a ")" after it goes +// too, and an unclosed "(" is left alone. +const cleanName = (name: string) => { + let out = '' + let cursor = 0 + for (;;) { + const open = name.indexOf('(', cursor) + if (open === -1) break + const close = name.indexOf(')', open + 1) + if (close === -1) break + let start = open + while (start > cursor && /\s/.test(name[start - 1])) start-- + out += name.slice(cursor, start) + cursor = close + 1 + } + return (out + name.slice(cursor)).trim() +} // ── item building ───────────────────────────────────────────────────────── interface TransportItem { diff --git a/client/src/components/shared/CustomTimePicker.tsx b/client/src/components/shared/CustomTimePicker.tsx index 6658bbaf2..cebfbd58d 100644 --- a/client/src/components/shared/CustomTimePicker.tsx +++ b/client/src/components/shared/CustomTimePicker.tsx @@ -23,7 +23,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00 // `m` falls back to NaN because a value with no colon ('' while the field is // empty) splits into a single part, and an absent minute has to read as unset. - const [h, m = NaN] = (parseMeridiemTime(value) ?? value ?? '').split(':').map(Number) + const [h, m = Number.NaN] = (parseMeridiemTime(value) ?? value ?? '').split(':').map(Number) const hour = Number.isNaN(h) ? null : h const minute = Number.isNaN(m) ? null : m diff --git a/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx b/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx index 034c0565d..3c9e2d0ee 100644 --- a/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminGitHubPanel.tsx @@ -119,14 +119,47 @@ export default function MAdminGitHubPanel({ isPrerelease = false }: { isPrerelea } const escapeHtml = (str: string) => str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + // `[label](url)` → anchor, exactly as /\[([^\]]+)\]\(([^)]+)\)/g did. Written as a + // scan because that pattern backtracks from both ends: `[^\]]` matches `[` and + // `[^)]` matches `(`, so a run of either made the engine retry the whole match from + // every one of them, reading to the end each time. Release bodies come off the + // GitHub API. Both parts are one-or-more, so `[](url)` and `[label]()` stay plain + // text; the label ends at the first `]`, the url at the first `)`. + const linkify = (text: string) => { + let out = '' + let cursor = 0 + let search = 0 + for (;;) { + const open = text.indexOf('[', search) + if (open === -1) break + const close = text.indexOf(']', open + 1) + if (close === -1) break // no `]` left: no later `[` can match either + // Every `[` up to `close` shares that first `]`, so they fail together. + if (close === open + 1 || text[close + 1] !== '(') { + search = close + 1 + continue + } + const end = text.indexOf(')', close + 2) + if (end === -1) break // no `)` left: no later `(` can close either + if (end === close + 2) { + search = close + 1 + continue + } + const label = text.slice(open + 1, close) + const url = text.slice(close + 2, end) + const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#' + out += `${text.slice(cursor, open)}${label}` + cursor = end + 1 + search = end + 1 + } + return out + text.slice(cursor) + } const inlineFormat = (text: string) => { - return escapeHtml(text) - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/`(.+?)`/g, '$1') - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => { - const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#' - return `${label}` - }) + return linkify( + escapeHtml(text) + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/`(.+?)`/g, '$1') + ) } for (const line of lines) { diff --git a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx index ee6c8ef1a..a850f30f4 100644 --- a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx +++ b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx @@ -315,7 +315,12 @@ export default function MJourneyEntrySheet({ } const addTag = () => { - const value = tagInput.trim().replace(/,+$/, '') + // Trailing commas dropped by a scan, not /,+$/: an unanchored ,+ before $ has to + // retry from every comma in the run, so a pasted string of them freezes the tab. + const trimmed = tagInput.trim() + let end = trimmed.length + while (end > 0 && trimmed[end - 1] === ',') end-- + const value = trimmed.slice(0, end) if (!value) return if (!tags.includes(value)) setTags(prev => [...prev, value]) setTagInput('') diff --git a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx index c81ba3425..f96886d85 100644 --- a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx @@ -54,9 +54,16 @@ function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence: function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit { return { role, sequence, name: l.name, code: null, lat: l.lat, lng: l.lng, timezone: null, local_date: date, local_time: time } } +// "Paris Charles de Gaulle (CDG)" → "Paris Charles de Gaulle", the same trim-plus- +// anchored-test the desktop TransportModal uses instead of /\s*\([A-Z]{3}\)\s*$/, +// because that leading \s* backtracks over every space in a long name. +function stripAirportCode(name: string): string { + const trimmed = name.trimEnd() + return /\([A-Z]{3}\)$/.test(trimmed) ? trimmed.slice(0, -5).trimEnd() : name +} function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null { if (!e || !e.code) return null - return { iata: e.code, icao: null, name: e.name, city: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''), country: '', lat: e.lat, lng: e.lng, tz: e.timezone || '' } + return { iata: e.code, icao: null, name: e.name, city: stripAirportCode(e.name), country: '', lat: e.lat, lng: e.lng, tz: e.timezone || '' } } function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null { if (!e) return null diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index fd827cfd7..d51da214a 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -841,7 +841,7 @@ describe('JourneyDetailPage', () => { }); }); - // ── Helper: open entry editor ────────────────────────��───────────────── + // ── Helper: open entry editor ─────────────────────────────────────────── async function openEntryEditor(user: ReturnType) { // The + button is inside the view controls row, after the tab group // Structure: div.justify-between > [div(tabs), button(+)] diff --git a/server/src/nest/llm-parse/clients/anthropic.client.ts b/server/src/nest/llm-parse/clients/anthropic.client.ts index 983b5b650..a1ce5a720 100644 --- a/server/src/nest/llm-parse/clients/anthropic.client.ts +++ b/server/src/nest/llm-parse/clients/anthropic.client.ts @@ -16,7 +16,11 @@ const TOOL_NAME = 'emit_reservations'; */ export class AnthropicClient implements LlmExtractionClient { async extract(input: LlmExtractionInput): Promise[]> { - const base = (input.baseUrl ?? 'https://api.anthropic.com').replace(/\/+$/, ''); + // The lookbehind pins the run to its own start. Without it `\/+$` restarts at every + // slash of a trailing run that turns out not to end the string, rescanning to the + // end each time; the assertion only rules out start positions the leftmost match + // could never have used, so the trimmed result is unchanged. + const base = (input.baseUrl ?? 'https://api.anthropic.com').replace(/(? s.replace(/[€$£¥\s]/g, ''); // A labeled total: "Gesamtpreis: 1.234,56 €", "Total Amount 99 USD", "Bezahlter Betrag 651,86 €". + // The symbol keeps the space that trails IT inside its own optional group: spelled + // `[€$£¥]?\s*`, a missing symbol left that \s* directly against the separator's \s*, + // and two adjacent runs split a stretch of spaces in n ways — quadratic on a label + // followed by a long indent and no amount. const labeled = text.match( - /(?:Gesamtpreis|Gesamtbetrag|Gesamtsumme|Total(?:\s*(?:price|amount))?|Amount|Summe|Betrag)\s*(?::\s*)?([€$£¥]?\s*\d[\d.,]*)\s*(EUR|USD|GBP|CHF|JPY|€|\$|£|¥)?/i, + /(?:Gesamtpreis|Gesamtbetrag|Gesamtsumme|Total(?:\s*(?:price|amount))?|Amount|Summe|Betrag)\s*(?::\s*)?((?:[€$£¥]\s*)?\d[\d.,]*)\s*(EUR|USD|GBP|CHF|JPY|€|\$|£|¥)?/i, ); if (labeled) return { price: strip(labeled[1]), currency: normCurrency(labeled[2] ?? labeled[1]) }; // Fallback: a standalone amount carrying a currency symbol on its own line (e.g. a voucher's // "¥9,400") — the price sits far from any label the pattern above can anchor to. The // indent is horizontal whitespace only: under /m the anchor already sits at every line // start, so a \s* run that could also eat the newlines just re-scans the document. - const symbol = text.match(/^[^\S\r\n]*([€$£¥]\s?\d[\d.,]*)\b/m); + // U+2028/U+2029 are line terminators too — /m starts a line after each one — so they + // have to leave the class for the same reason \r\n do. `[^\S\r\n]` still contained + // them (they are \s), which made a run of them both a line start and indent the class + // could eat: 16k of U+2028 took 348ms, and the run grows quadratically. + const symbol = text.match(/^[^\S\r\n\u2028\u2029]*([€$£¥]\s?\d[\d.,]*)\b/m); if (symbol) return { price: strip(symbol[1]), currency: normCurrency(symbol[1]) }; return null; } diff --git a/server/src/nest/llm-parse/router/ollama-format.client.ts b/server/src/nest/llm-parse/router/ollama-format.client.ts index 49db787fe..cb7c3822a 100644 --- a/server/src/nest/llm-parse/router/ollama-format.client.ts +++ b/server/src/nest/llm-parse/router/ollama-format.client.ts @@ -32,7 +32,12 @@ export interface EnforcedExtractInput { /** Resolve the native API base from a config base URL that may end in `/v1`. */ export function toNativeBase(baseUrl: string): string { - return baseUrl.replace(/\/+$/, '').replace(/\/v1$/, ''); + // Trailing slashes come off as a scan, not /\/+$/: that pattern is unanchored at the + // front, so the engine restarts it at every slash of a run and rescans to the end each + // time (quadratic — 25s on 200k). Same result: the maximal trailing run goes. + let end = baseUrl.length; + while (end > 0 && baseUrl[end - 1] === '/') end--; + return baseUrl.slice(0, end).replace(/\/v1$/, ''); } /** diff --git a/server/src/nest/llm-parse/text-extract.ts b/server/src/nest/llm-parse/text-extract.ts index 05657be02..8adc11bcf 100644 --- a/server/src/nest/llm-parse/text-extract.ts +++ b/server/src/nest/llm-parse/text-extract.ts @@ -265,7 +265,9 @@ function stripPageMarkers(text: string): string { function cleanPdfText(text: string): string { return stripPageMarkers(text) .replace(/[ \t]+/g, ' ') - .replace(/\b(?:[A-Z] ){2,}[A-Z]\b/g, m => m.replaceAll(' ', '')) + // Same run of three-or-more spaced capitals, written so the repeated group no + // longer overlaps the [A-Z] behind it and has to hand characters back to it. + .replace(/\b[A-Z](?: [A-Z]){2,}\b/g, m => m.replaceAll(' ', '')) .replace(/ *\n */g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); diff --git a/server/src/nest/mcp-transport/mcp-transport.service.ts b/server/src/nest/mcp-transport/mcp-transport.service.ts index 9d521d475..e8e3b96fa 100644 --- a/server/src/nest/mcp-transport/mcp-transport.service.ts +++ b/server/src/nest/mcp-transport/mcp-transport.service.ts @@ -87,8 +87,19 @@ export function sameScopes(a: string[] | null, b: string[] | null): boolean { return left.every((scope, i) => scope === right[i]); } +/** + * Drop the trailing slashes of a base URL, as a scan rather than /\/+$/: that pattern + * is unanchored at the front, so the engine restarts it at every slash of a run and + * rescans to the end each time (quadratic — 25s on 200k). Same string either way. + */ +function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value[end - 1] === '/') end--; + return value.slice(0, end); +} + export function setAuthChallenge(res: Response, error = 'invalid_token'): void { - const base = (getMcpSafeUrl() || '').replace(/\/+$/, ''); + const base = trimTrailingSlashes(getMcpSafeUrl() || ''); // RFC 9728 §5: resource with path component /mcp → PRM URL must include the path res.set('WWW-Authenticate', `Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`); @@ -129,7 +140,7 @@ export class McpTransportService { if (!result) return null; // RFC 8707: audience must always match this resource endpoint. // Pre-audit tokens with audience=null are revoked by the SEC-H6 migration. - const expected = `${(getMcpSafeUrl() || '').replace(/\/+$/, '')}/mcp`; + const expected = `${trimTrailingSlashes(getMcpSafeUrl() || '')}/mcp`; if (result.audience !== expected) return null; return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false }; } diff --git a/server/src/nest/oidc/oidc.controller.ts b/server/src/nest/oidc/oidc.controller.ts index c4d3bf96b..37cc8daec 100644 --- a/server/src/nest/oidc/oidc.controller.ts +++ b/server/src/nest/oidc/oidc.controller.ts @@ -52,7 +52,9 @@ export class OidcController { res.status(500).json({ error: 'APP_URL is not configured. OIDC cannot be used.' }); return; } - const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`; + // `(? { const folded = foldICS(line); - expect(folded).not.toContain('�'); + expect(folded).not.toContain('\uFFFD'); expect(unfold(folded)).toBe(line); for (const part of folded.split('\r\n')) expect(octets(part)).toBeLessThanOrEqual(75); }); @@ -1150,7 +1150,7 @@ describe('foldICS', () => { const folded = foldICS(line); - expect(folded).not.toContain('�'); + expect(folded).not.toContain('\uFFFD'); expect(unfold(folded)).toBe(line); for (const part of folded.split('\r\n')) expect(octets(part)).toBeLessThanOrEqual(75); });