diff --git a/client/src/components/Assistant/AssistantFitCheck.tsx b/client/src/components/Assistant/AssistantFitCheck.tsx index 41d5fc84d..39ba60f72 100644 --- a/client/src/components/Assistant/AssistantFitCheck.tsx +++ b/client/src/components/Assistant/AssistantFitCheck.tsx @@ -66,9 +66,9 @@ export default function AssistantFitCheck({ changes }: { changes: readonly DayCh if (ok) return pill; return ( - + ); } diff --git a/client/src/components/Assistant/assistantShare.test.ts b/client/src/components/Assistant/assistantShare.test.ts index f50ca3f9c..0ec5d128f 100644 --- a/client/src/components/Assistant/assistantShare.test.ts +++ b/client/src/components/Assistant/assistantShare.test.ts @@ -76,6 +76,23 @@ describe('markdownToText', () => { it('FE-AI-SHARE-002: a link whose text is its address stays one address', () => { expect(markdownToText('[https://trek.app](https://trek.app)')).toBe('https://trek.app'); }); + + it('FE-AI-SHARE-006: pictures, odd links and divider rows read as before', () => { + expect(markdownToText('![Map](a.png) and [[nested]](u) and [a b](x y) and [](z) and [ok](u)')).toBe( + 'Map and [[nested]](u) and [a b](x y) and [](z) and ok (u)' + ); + expect(markdownToText('|:-|-:|\n| a | b |')).toBe('a, b'); + expect(markdownToText('| - | x |')).toBe('-, x'); + expect(markdownToText('# Wide heading\n- wide item')).toBe('Wide heading\n• wide item'); + }); + + it('FE-AI-SHARE-007: long runs of brackets, spaces and dashes are read in one pass', () => { + const started = Date.now(); + const long = 20000; + markdownToText(`${'['.repeat(long)}\n${'![a](b '.repeat(long / 7)}\n${'-'.repeat(long)}\n#${' '.repeat(long)}x`); + markdownToText(`|${' - |'.repeat(long / 4)} x`); + expect(Date.now() - started).toBeLessThan(2000); + }); }); describe('chatTextOf', () => { diff --git a/client/src/components/Assistant/assistantShare.ts b/client/src/components/Assistant/assistantShare.ts index 2738d54a1..65ca9c181 100644 --- a/client/src/components/Assistant/assistantShare.ts +++ b/client/src/components/Assistant/assistantShare.ts @@ -14,8 +14,19 @@ export const SHARE_MAX_CHARS = 4800; type Translate = (key: string, params?: Record) => string; -/** A table's divider row (`|---|:--:|`), which has no words. */ -const TABLE_RULE = /^\|?(?:\s*:?-+:?\s*\|)+\s*:?-*:?\s*$/; +/** What a divider cell holds once trimmed: dashes, with a colon at either end for alignment. */ +const RULE_CELL = /^:?-*:?$/; + +/** A table's divider row (`|---|:--:|`), which has no words. Read cell by cell, so no pattern backtracks over it. */ +function isTableRule(line: string): boolean { + const cells = (line.startsWith('|') ? line.slice(1) : line).split('|'); + const last = cells.pop() ?? ''; + if (cells.length === 0 || !RULE_CELL.test(last.trim())) return false; + return cells.every((cell) => { + const trimmed = cell.trim(); + return trimmed.includes('-') && RULE_CELL.test(trimmed); + }); +} /** One table row as its cells in a line. */ function tableRow(line: string): string { @@ -28,11 +39,45 @@ function tableRow(line: string): string { .join(', '); } +/** + * Rewrites every `[text](url)` of a line (with a leading `!` for a picture), found by scanning: + * a `[` and the first `]` after it decide the match, so a long line of brackets is read once. + */ +function rewriteBrackets(line: string, picture: boolean, build: (text: string, url: string) => string): string { + let out = ''; + let from = 0; + let paren = -1; + let open = line.indexOf('['); + while (open !== -1) { + if (picture && (open === 0 || line[open - 1] !== '!')) { + open = line.indexOf('[', open + 1); + continue; + } + const close = line.indexOf(']', open + 1); + if (close === -1) break; + let next = close; + if (line[close + 1] === '(') { + if (paren < close + 2) paren = line.indexOf(')', close + 2); + if (paren === -1) break; + let clean = true; + for (let at = close + 2; at < paren && clean; at++) clean = !/\s/.test(line.charAt(at)); + const text = line.slice(open + 1, close); + const url = line.slice(close + 2, paren); + if (clean && (picture || (text !== '' && url !== ''))) { + out += line.slice(from, picture ? open - 1 : open) + build(text, url); + from = paren + 1; + next = paren; + } + } + open = line.indexOf('[', next + 1); + } + return out + line.slice(from); +} + /** The marks of one line of running text: emphasis, code, links and pictures. */ function inline(line: string): string { - return line - .replace(/!\[([^\]]*)\]\([^)\s]*\)/g, '$1') - .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text: string, url: string) => (text === url ? url : `${text} (${url})`)) + const pictures = rewriteBrackets(line, true, (text) => text); + return rewriteBrackets(pictures, false, (text, url) => (text === url ? url : `${text} (${url})`)) .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/__([^_]+)__/g, '$1') .replace(/(^|[^\w*])\*([^*\s][^*]*)\*/g, '$1$2') @@ -42,11 +87,11 @@ function inline(line: string): string { /** One line of Markdown as text, or null for a line that only carries layout (a fence, a table rule). */ function plainLine(line: string): string | null { if (/^\s*(?:```|~~~)/.test(line)) return null; - if (TABLE_RULE.test(line.trim())) return null; - const heading = /^\s{0,3}#{1,6}\s+(.*)$/.exec(line); - if (heading) return inline(heading[1] ?? ''); - const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line); - if (bullet) return `${bullet[1] ?? ''}• ${inline(bullet[2] ?? '')}`; + if (isTableRule(line.trim())) return null; + const heading = /^\s{0,3}#{1,6}\s(.*)$/.exec(line); + if (heading) return inline((heading[1] ?? '').trimStart()); + const bullet = /^(\s*)[-*+]\s(.*)$/.exec(line); + if (bullet) return `${bullet[1] ?? ''}• ${inline((bullet[2] ?? '').trimStart())}`; const quote = /^\s*>\s?(.*)$/.exec(line); if (quote) return inline(quote[1] ?? ''); if (line.trim().startsWith('|')) return tableRow(inline(line)); diff --git a/client/src/components/Assistant/cards/ChangeCardSlots.tsx b/client/src/components/Assistant/cards/ChangeCardSlots.tsx index fb1bba091..85d56173f 100644 --- a/client/src/components/Assistant/cards/ChangeCardSlots.tsx +++ b/client/src/components/Assistant/cards/ChangeCardSlots.tsx @@ -329,8 +329,10 @@ export function ChangeRows({ items, folded, excluded, disabled, undone, undoable const Group = view?.Group; const Run = view?.Run; const body: ReactNode[] = []; - for (let i = 0; i < run.length; i++) { + let i = 0; + while (i < run.length) { let end = i; + let next = i + 1; while (end < run.length && runnable(run[end] as ChangeItem, view)) end++; if (Run && end - i >= 2) { const taken = run.slice(i, end); @@ -342,8 +344,9 @@ export function ChangeRows({ items, folded, excluded, disabled, undone, undoable ); } - i = end - 1; + next = end; } else body.push(row(run[i] as ChangeItem)); + i = next; } if (!Group || !first) return body; const group = (place: 'head' | 'foot') => ( diff --git a/client/src/components/Assistant/cards/collection/collectionCardParts.tsx b/client/src/components/Assistant/cards/collection/collectionCardParts.tsx index 55f1c4ccf..98c424fd3 100644 --- a/client/src/components/Assistant/cards/collection/collectionCardParts.tsx +++ b/client/src/components/Assistant/cards/collection/collectionCardParts.tsx @@ -75,7 +75,7 @@ function LabelChips({ labels }: { labels: readonly CollectionCardLabel[] }) { ))} {rest.length > 0 && ( label.name).join(', ')} placement="top"> - + +{rest.length} diff --git a/client/src/components/Roadtrip/ChargingInfo.tsx b/client/src/components/Roadtrip/ChargingInfo.tsx index 6a24941eb..99fc247b6 100644 --- a/client/src/components/Roadtrip/ChargingInfo.tsx +++ b/client/src/components/Roadtrip/ChargingInfo.tsx @@ -1,11 +1,11 @@ -import { useEffect, useState } from 'react' -import { PlugZap, Coins, ChevronDown } from 'lucide-react' -import { cheapestEnergy, type ChargingEnergyPrice, type ChargingInfo as Info } from '@trek/shared' -import { useTranslation } from '../../i18n/TranslationContext' -import { useTripStore } from '../../store/tripStore' -import { chargingRepo } from '../../repo/chargingRepo' -import { isEffectivelyOffline } from '../../sync/networkMode' -import { Tooltip } from '../shared/Tooltip' +import { cheapestEnergy, type ChargingEnergyPrice, type ChargingInfo as Info } from '@trek/shared'; +import { ChevronDown, Coins, PlugZap } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from '../../i18n/TranslationContext'; +import { chargingRepo } from '../../repo/chargingRepo'; +import { useTripStore } from '../../store/tripStore'; +import { isEffectivelyOffline } from '../../sync/networkMode'; +import { Tooltip } from '../shared/Tooltip'; /** * Which station the panel is asking about, one way or the other. @@ -18,85 +18,203 @@ import { Tooltip } from '../shared/Tooltip' */ type ChargingTarget = | { placeId: number; lat?: undefined; lng?: undefined; name?: undefined } - | { placeId?: undefined; lat: number; lng: number; name: string } + | { placeId?: undefined; lat: number; lng: number; name: string }; /** * A charger's two compact badges: the free points of all its points, when that is known * and fresh, and its cheapest energy price. The live badge on the rail draws them, and so * does the assistant's card of one look at the charger, which never asks again. */ -export function ChargingBadges({ label, known, fresh = true, available, total, energy }: { +export function ChargingBadges({ + label, + known, + fresh = true, + available, + total, + energy, +}: { /** What the availability badge says when it is pointed at. */ - label: string - known: boolean + label: string; + known: boolean; /** False for a count checked a while ago: still shown, but no longer green. */ - fresh?: boolean - available: number | null - total: number | null - energy: ChargingEnergyPrice | null + fresh?: boolean; + available: number | null; + total: number | null; + energy: ChargingEnergyPrice | null; }) { - const { t } = useTranslation() - const price = (value: number, currency: string) => new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value) - return - 0 ? 'bg-success-soft text-success' : 'bg-surface-secondary text-content-muted'}`}>{known ? `${available}/${total}` : '?'} - {energy && {price(energy.price, energy.currency)}/kWh*} - + const { t } = useTranslation(); + const price = (value: number, currency: string) => + new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value); + return ( + + + 0 ? 'bg-success-soft text-success' : 'bg-surface-secondary text-content-muted'}`} + > + + {known ? `${available}/${total}` : '?'} + + + {energy && ( + + + + {price(energy.price, energy.currency)}/kWh* + + + )} + + ); } -export default function ChargingInfo({ placeId, lat, lng, name, compact = false }: ChargingTarget & { compact?: boolean }) { - const tripId = useTripStore(s => s.trip?.id) - const { t } = useTranslation() - const [info, setInfo] = useState(null) - const [loading, setLoading] = useState(true) +export default function ChargingInfo({ + placeId, + lat, + lng, + name, + compact = false, +}: ChargingTarget & { compact?: boolean }) { + const tripId = useTripStore((s) => s.trip?.id); + const { t } = useTranslation(); + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); useEffect(() => { - let active = true - setInfo(null); setLoading(true) + let active = true; + setInfo(null); + setLoading(true); const refresh = async () => { - if (!tripId || document.hidden) return + if (!tripId || document.hidden) return; try { - const value = placeId != null - ? await chargingRepo.read(tripId, placeId) - : await chargingRepo.readAt(tripId, lat!, lng!, name!) - if (active) setInfo(value) + const value = + placeId != null + ? await chargingRepo.read(tripId, placeId) + : await chargingRepo.readAt(tripId, lat!, lng!, name!); + if (active) setInfo(value); + } catch { + if (active) setInfo(null); + } finally { + if (active) setLoading(false); } - catch { if (active) setInfo(null) } - finally { if (active) setLoading(false) } - } - const offline = () => { setInfo(null); setLoading(false) } - void refresh() - const timer = window.setInterval(() => void refresh(), 60000) - window.addEventListener('offline', offline) - window.addEventListener('online', refresh) - document.addEventListener('visibilitychange', refresh) - return () => { active = false; clearInterval(timer); window.removeEventListener('offline', offline); window.removeEventListener('online', refresh); document.removeEventListener('visibilitychange', refresh) } + }; + const offline = () => { + setInfo(null); + setLoading(false); + }; + void refresh(); + const timer = window.setInterval(() => void refresh(), 60000); + window.addEventListener('offline', offline); + window.addEventListener('online', refresh); + document.addEventListener('visibilitychange', refresh); + return () => { + active = false; + clearInterval(timer); + window.removeEventListener('offline', offline); + window.removeEventListener('online', refresh); + document.removeEventListener('visibilitychange', refresh); + }; // The identity of the station, whichever way it was given: a changed coordinate is a // different charger and has to re-ask, exactly as a changed place id does. - }, [tripId, placeId, lat, lng, name]) - const known = !isEffectivelyOffline() && info?.available != null && !info.stale && Date.now() - Date.parse(info.checkedAt) < 120000 - const status = loading ? t('common.loading') : known ? `${info.available}/${info.total} ${t('roadtrip.charging.available')}` : t(info?.stale ? 'roadtrip.charging.stale' : 'roadtrip.charging.unknown') - const components = info?.tariffs.flatMap(tariff => tariff.components.map(component => ({ ...component, currency: tariff.currency }))) ?? [] + }, [tripId, placeId, lat, lng, name]); + const known = + !isEffectivelyOffline() && + info?.available != null && + !info.stale && + Date.now() - Date.parse(info.checkedAt) < 120000; + const status = loading + ? t('common.loading') + : known + ? `${info.available}/${info.total} ${t('roadtrip.charging.available')}` + : t(info?.stale ? 'roadtrip.charging.stale' : 'roadtrip.charging.unknown'); + const components = + info?.tariffs.flatMap((tariff) => + tariff.components.map((component) => ({ ...component, currency: tariff.currency })) + ) ?? []; // The same price the assistant's card shows: tax included, no condition, one currency. - const cheapest = info ? cheapestEnergy(info.tariffs) : null - const price = (value: number, currency: string) => new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value) - const label = `${status}${info?.updatedAt ? ` (${new Date(info.updatedAt).toLocaleString()})` : ''}` - if (compact) return - return
- - - {t('roadtrip.poi.charging')} - {status} - {cheapest && {price(cheapest.price, cheapest.currency)}/kWh*} - - -
- {info?.station &&

{info.station}

} -

{t('roadtrip.charging.prices')}

- {!components.length &&

{info?.pricesUnavailable ? `${t('common.error')}: ` : ''}{t('roadtrip.charging.unknown')}

} - {components.map((component, index) =>

{price(component.price, component.currency)}{component.kind === 'ENERGY' ? '/kWh' : ['TIME', 'PARKING_TIME'].includes(component.kind) ? '/h' : ''} {component.kind === 'FLAT' ? 'Σ' : ''}{component.conditional || !component.taxIncluded ? '*' : ''}{component.afterSeconds != null && component.afterSeconds > 0 ? ` (> ${Math.round(component.afterSeconds / 60)} min)` : ''}

)} - {!!components.length &&

* {t('roadtrip.charging.note')}

} -
-

{t('roadtrip.charging.coverage')}

- {info?.source &&

{info.source} · MobiData BW
{info.license}{info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`}

} -
-
+ const cheapest = info ? cheapestEnergy(info.tariffs) : null; + const price = (value: number, currency: string) => + new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value); + const label = `${status}${info?.updatedAt ? ` (${new Date(info.updatedAt).toLocaleString()})` : ''}`; + if (compact) + return ( + + ); + return ( +
+ + + {t('roadtrip.poi.charging')} + {status} + {cheapest && ( + {price(cheapest.price, cheapest.currency)}/kWh* + )} + + +
+ {info?.station &&

{info.station}

} +
+

{t('roadtrip.charging.prices')}

+ {!components.length && ( +

+ {info?.pricesUnavailable ? `${t('common.error')}: ` : ''} + {t('roadtrip.charging.unknown')} +

+ )} + {components.map((component, index) => ( +

+ {price(component.price, component.currency)} + {component.kind === 'ENERGY' + ? '/kWh' + : ['TIME', 'PARKING_TIME'].includes(component.kind) + ? '/h' + : ''}{' '} + {component.kind === 'FLAT' ? 'Σ' : ''} + {component.conditional || !component.taxIncluded ? '*' : ''} + {component.afterSeconds != null && component.afterSeconds > 0 + ? ` (> ${Math.round(component.afterSeconds / 60)} min)` + : ''} +

+ ))} + {!!components.length &&

* {t('roadtrip.charging.note')}

} +
+

{t('roadtrip.charging.coverage')}

+ {info?.source && ( +

+ + {info.source} + {' '} + ·{' '} + + MobiData BW + +
+ {info.license} + {info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`} +

+ )} +
+
+ ); } diff --git a/server/src/nest/assistant/assistant-redact.ts b/server/src/nest/assistant/assistant-redact.ts index 1d0296800..669ecf6b5 100644 --- a/server/src/nest/assistant/assistant-redact.ts +++ b/server/src/nest/assistant/assistant-redact.ts @@ -14,7 +14,8 @@ const SECRET_KEYS = new Set(['email', 'confirmation_number', 'confirmation', 'ph /** Keys that cost a lot and tell the model nothing it plans with. */ const DROPPED_KEY = /^(image_url|cover_image|avatar|avatar_url|metadata|place_image|trip_cover)$|geometry/; -const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g; +// The lookbehind keeps a match from starting inside a run of address characters, so a long run without an @ is read once. +const EMAIL = /(? a.localeCompare(b)).map(monthKey))].slice(0, max); return keys.map((key) => monthOf(`${key}-01`)); } @@ -58,7 +58,9 @@ function monthsOfDates(dates: readonly string[], max: number): CardMonth[] { * from January to December, widened to the months the answer holds days of. */ function focusMonths(input: AnswerInput, entries: readonly VacayEntryRow[], year: number): CardMonth[] { - const span = [`${year}-01`, `${year}-12`, ...entries.map((entry) => monthKey(entry.date))].sort(); + const span = [`${year}-01`, `${year}-12`, ...entries.map((entry) => monthKey(entry.date))].sort((a, b) => + a.localeCompare(b), + ); const first = span[0] ?? `${year}-01`; const last = span[span.length - 1] ?? `${year}-12`; const inside = (key: string) => key >= first && key <= last; diff --git a/server/src/nest/assistant/cards/vacay/vacay-frame.ts b/server/src/nest/assistant/cards/vacay/vacay-frame.ts index 4256e0a0c..19d610ef4 100644 --- a/server/src/nest/assistant/cards/vacay/vacay-frame.ts +++ b/server/src/nest/assistant/cards/vacay/vacay-frame.ts @@ -36,7 +36,7 @@ export function inMonths(date: string, months: readonly CardMonth[]): boolean { /** The dated days of the trip, from its days or else its first and last date (a year of them at most). */ function tripDatesOf(card: TripCard): string[] { const dated = card.days.flatMap((day) => (day.date ? [day.date] : [])); - if (dated.length > 0) return [...new Set(dated)].sort(); + if (dated.length > 0) return [...new Set(dated)].sort((a, b) => a.localeCompare(b)); if (!card.startDate || !card.endDate || card.startDate > card.endDate) return []; const dates: string[] = []; const day = new Date(`${card.startDate}T00:00:00Z`); diff --git a/server/src/nest/photos/photo-signals-runner.ts b/server/src/nest/photos/photo-signals-runner.ts index e643949d8..dfbef8acc 100644 --- a/server/src/nest/photos/photo-signals-runner.ts +++ b/server/src/nest/photos/photo-signals-runner.ts @@ -137,7 +137,7 @@ function round(value: number, digits: number): number { export function bitsToHex(bits: string): string | null { if (!/^[01]{64}$/.test(bits)) return null; let out = ''; - for (let i = 0; i < 64; i += 4) out += parseInt(bits.slice(i, i + 4), 2).toString(16); + for (let i = 0; i < 64; i += 4) out += Number.parseInt(bits.slice(i, i + 4), 2).toString(16); return out; } diff --git a/server/src/nest/tours/hike-routing.service.ts b/server/src/nest/tours/hike-routing.service.ts index 9dc12e6c2..bc59af506 100644 --- a/server/src/nest/tours/hike-routing.service.ts +++ b/server/src/nest/tours/hike-routing.service.ts @@ -137,8 +137,9 @@ export class HikeRoutingService { userId: number, tripId: number, request: HikePlanRequest, - { grades: withGrades }: HikePlanOptions = { grades: true }, + options?: HikePlanOptions, ): Promise { + const withGrades = options?.grades ?? true; // The tools check their own requests; a change plans through the points a hike has saved, held here to the same. const checked = hikePlanRequestSchema.safeParse(request); if (!checked.success) throw badRequest(checked.error.issues.map((issue) => issue.message).join('; ')); diff --git a/server/tests/unit/nest/assistant/assistant-redact.test.ts b/server/tests/unit/nest/assistant/assistant-redact.test.ts index 4f2f0ae47..2b22aed66 100644 --- a/server/tests/unit/nest/assistant/assistant-redact.test.ts +++ b/server/tests/unit/nest/assistant/assistant-redact.test.ts @@ -29,6 +29,14 @@ describe('redactForProvider', () => { }); }); + it('ASTRED-003: masks an address inside a long run of address characters, and reads a run without one quickly', () => { + expect(redactForProvider('x'.repeat(50) + '.y@mail.example.test!')).toBe('[hidden]!'); + const started = Date.now(); + redactForProvider('a'.repeat(100000)); + redactForProvider('a@'.repeat(50000)); + expect(Date.now() - started).toBeLessThan(2000); + }); + it("ASTRED-002: drops a booked place's picture and the trip's cover, wherever they sit", () => { expect( redactForProvider({ diff --git a/shared/src/book/photo-pick.ts b/shared/src/book/photo-pick.ts index 4db3b90cf..9d550d607 100644 --- a/shared/src/book/photo-pick.ts +++ b/shared/src/book/photo-pick.ts @@ -182,7 +182,7 @@ const HASH = /^[0-9a-f]{16}$/i; function parseHash(hex: string | null | undefined): Hash | null { if (!hex || !HASH.test(hex)) return null; - return [parseInt(hex.slice(0, 8), 16), parseInt(hex.slice(8), 16)]; + return [Number.parseInt(hex.slice(0, 8), 16), Number.parseInt(hex.slice(8), 16)]; } /** The set bits of a 32-bit number. */