diff --git a/README.md b/README.md index 38bff61..de8cc93 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 - Opens saved rides from the dashboard's Sessions button in a slide-out tray with Calendar, List, and Statistics views. The month calendar marks every day with rides and makes each event directly selectable, while the virtualized chronological list retains paginated loading for very large histories. Statistics are updated transactionally whenever a session is saved, replaced, imported, or deleted, then read from compact IndexedDB rollups instead of rescanning telemetry. All-time totals cover rides, distance, time, climbing, downhill, calories, speed, power, cadence, and heart rate; their responsive cards use at most three columns and always show complete numeric values instead of truncating them. The statistics view also graphs the same canonical profile-weight history shown in Profile, with values converted into the selected display unit. Personal-best cards open their source sessions, and dedicated weekly, monthly, yearly, and complete-history graphs show distance, time, elevation, calories, ride count, average speed, power, cadence, and heart rate. Trends remembers both the selected chart metric and timeframe. Detailed session metrics and charts, clear date ranges for rides that span midnight, keyboard navigation with grouped shortcut help, and permanent deletion remain available. The tray remembers its active view, selected session, list scroll position, and each session's independent detail-pane scroll position after a page reload. - Downloads saved rides as standards-compliant FIT activities for direct upload to Strava and other fitness services, including indoor-cycling and creator metadata, UTC and local timestamps, distance, speed, power, cadence, estimated crank revolutions and work, heart rate, resistance, elevation, calories, and ride totals. Each FIT filename includes a stable session token for reliable upload identity. TCX export remains available for the richer Ride Control round trip, including virtual gear, terrain workout metadata, ride feeling, session description, and the original session identifier. - Creates an on-demand 1200×630 workout card for sharing on X from a stable, stateless RideControl.xyz link containing selected summary stats, a compact route map and elevation preview, and accurate personal-best callouts. Public GPX workouts link back to their exact RideControl route. Cloudflare regenerates an evicted image from the link and serves it with immutable cache headers; no share data is stored in KV or R2. Sharing is explicit and does not publish raw ride samples, comments, or profile details. -- Imports individual FIT or TCX activities, or every supported activity inside nested folders in a mixed-format ZIP, directly into local session history. Compatible ride data is preserved, including complete route details in RideControl TCX files and saved-route identity and course position in RideControl FIT files; FIT routes reconnect automatically when the matching built-in or saved route is available. Duplicates are detected across formats by identifier or stable activity data, and invalid files do not stop the rest of a batch; imported rides permanently retain their import timestamp and a subtle import icon, while only the latest batch remains highlighted until the history tray closes. +- Imports individual FIT or TCX activities, or every supported activity inside nested folders in a mixed-format ZIP, directly into local session history. Full exports can be restored without application-imposed file-count, per-activity-size, or total-archive-size ceilings. ZIPs are read incrementally and activities are parsed and saved sequentially rather than inflating the entire archive first; available browser memory and storage still determine practical capacity. Compatible ride data is preserved, including complete route details in RideControl TCX files and saved-route identity and course position in RideControl FIT files; FIT routes reconnect automatically when the matching built-in or saved route is available. Duplicates are detected across formats by identifier or stable activity data, and invalid activity files do not stop the rest of a batch. Import failures and partial successes with errors appear in a dismissible dialog over any Sessions tab, with affected filenames and error details; successfully imported rides remain saved if a later archive entry fails. Imported rides permanently retain their import timestamp and a subtle import icon, while only the latest batch remains highlighted until the history tray closes. - Downloads every locally saved ride at once as a compressed ZIP of individual FIT or TCX files, with TCX selected by default, the rider's format choice remembered locally, and collision-safe filenames when sessions share the same start time. - Continues any saved session in a new unsaved copy while preserving its recorded time, distance, calories, samples, averages, maximums, and original start time. Linked course sessions expose compact part-number navigation through the continuation path, plus an all-parts view that combines every session on that path without mixing in alternate branches. Active rides are checkpointed locally and restored after a page reload; the restored dashboard explains that ride data remains safe while Bluetooth devices may need time to reconnect before riding continues, then automatically removes that notice once the trainer and every other paired ride device are connected again. - Protects recorded active rides with a browser confirmation before refresh or close, and presents the save workflow before starting or continuing another session. diff --git a/src/components/session-history.tsx b/src/components/session-history.tsx index 1cf49d8..5c3aee8 100644 --- a/src/components/session-history.tsx +++ b/src/components/session-history.tsx @@ -39,6 +39,7 @@ import { SelectMenu } from './select-menu'; import { SessionCalendar } from './session-calendar'; import { SessionDetail } from './session-detail'; import { SessionHistoryList } from './session-history-list'; +import { SessionImportResultDialog } from './session-import-dialog'; import { SessionStatistics } from './session-statistics'; import { SideTray } from './side-tray'; import { Tabs } from './tabs'; @@ -56,6 +57,23 @@ const SESSION_DOWNLOAD_FORMAT_OPTIONS = [ { label: 'TCX', value: ACTIVITY_FILE_FORMAT.TCX }, ] as const; +function SessionHistoryStatus({ status, total }: { status: string; total: number }) { + const count = total.toLocaleString(); + const sessions = `${count} ${total === 1 ? 'session' : 'sessions'}`; + return ( +

+ {count} + {status ? · {status} : null} +

+ ); +} + export function SessionHistory({ onClose, onSelectCalendarMonth, @@ -82,6 +100,7 @@ export function SessionHistory({ weightHistory?: readonly RiderWeightEntry[]; }) { const { + clearImportResult, combinedJourney, deleteSelectedSession: deleteHistorySession, deleting, @@ -92,6 +111,7 @@ export function SessionHistory({ highlightedSessionIds, importActivityFile, importing, + importResult, loading, loadMore, revision, @@ -124,6 +144,8 @@ export function SessionHistory({ const [downloadFormat, setDownloadFormat] = useState(loadSessionDownloadFormat); const importInput = useRef(null); + const importButton = useRef(null); + const restoreImportFocus = useRef(false); const transferring = exporting || importing; const navigationSummaries = historyView === SESSION_HISTORY_VIEW.CALENDAR ? calendarSummaries : summaries; @@ -135,6 +157,22 @@ export function SessionHistory({ } }, [open]); + const closeImportResult = useCallback(() => { + restoreImportFocus.current = true; + clearImportResult(); + }, [clearImportResult]); + + useEffect(() => { + if (importResult || !restoreImportFocus.current) { + return; + } + restoreImportFocus.current = false; + // The disabled upload button loses focus before the dialog can remember it. + if (open) { + importButton.current?.focus(); + } + }, [importResult, open]); + const selectSession = useCallback( (id: string) => { setDeleteConfirmationOpen(false); @@ -155,7 +193,7 @@ export function SessionHistory({ }, [deleteHistorySession]); useEffect(() => { - if (!open) { + if (!open || importResult) { return; } const selectAdjacent = (event: KeyboardEvent, direction: 'next' | 'previous') => { @@ -230,6 +268,7 @@ export function SessionHistory({ deleteSelectedSession, historyHelpOpen, historyView, + importResult, onClose, open, selectSession, @@ -266,7 +305,9 @@ export function SessionHistory({ } else if (selected) { detail = ( Sessions -

- {total.toLocaleString()} - {historyStatus ? ( - · {historyStatus} - ) : null} -

+
importInput.current?.click()} + ref={importButton} type="button" > {importing ? 'Importing…' : 'Import FIT/TCX'} @@ -463,6 +494,14 @@ export function SessionHistory({ shortcuts={historyKeyboardShortcuts} title="History keyboard controls" /> + {open && importResult ? ( + + ) : null} ); } diff --git a/src/components/session-import-dialog.tsx b/src/components/session-import-dialog.tsx new file mode 100644 index 0000000..68b0bbd --- /dev/null +++ b/src/components/session-import-dialog.tsx @@ -0,0 +1,132 @@ +import { useEffect, useRef } from 'react'; +import { useCloseOnEscape, useDialogInitialFocus } from '../hooks/use-dialog-behavior'; +import { type ActivityImportResult, activityImportResultMessage } from '../lib/activity-import'; + +export function SessionImportResultDialog({ + error, + fileName, + onClose, + result, +}: { + error?: string; + fileName: string; + onClose: () => void; + result?: ActivityImportResult; +}) { + const dialogRef = useRef(null); + const detailsRef = useRef(null); + const closeButtonRef = useRef(null); + const doneButtonRef = useDialogInitialFocus(); + useCloseOnEscape(true, onClose); + + useEffect(() => { + const dialog = dialogRef.current; + const containFocus = (event: KeyboardEvent) => { + if (event.key !== 'Tab') { + return; + } + let target: HTMLElement | null = detailsRef.current; + if (event.target === detailsRef.current) { + target = event.shiftKey ? closeButtonRef.current : doneButtonRef.current; + } else if (event.shiftKey && event.target === closeButtonRef.current) { + target = doneButtonRef.current; + } else if (!event.shiftKey && event.target === doneButtonRef.current) { + target = closeButtonRef.current; + } + event.preventDefault(); + target?.focus(); + }; + dialog?.addEventListener('keydown', containFocus); + return () => dialog?.removeEventListener('keydown', containFocus); + }, [doneButtonRef]); + + return ( +
+ + +
+

+ {result + ? activityImportResultMessage(result) + : 'The selected file could not be imported.'} +

+ {error ? ( +

+ {error} +

+ ) : null} + {result ? ( +
    + {result.failures.map((failure) => ( +
  • +

    + {failure.fileName} +

    +

    + {failure.message} +

    +
  • + ))} +
+ ) : null} +

+ Review the errors, fix or export the affected files again, then retry the + import. Any successfully imported sessions are saved; existing sessions will + be skipped as duplicates when you retry. +

+
+
+ +
+ +
+ ); +} diff --git a/src/hooks/use-session-history.ts b/src/hooks/use-session-history.ts index 18deaf0..051147a 100644 --- a/src/hooks/use-session-history.ts +++ b/src/hooks/use-session-history.ts @@ -1,6 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { ACTIVITY_FILE_FORMAT, type ActivityFileFormat } from '../lib/activity-file'; -import { activityImportResultMessage, importActivityUpload } from '../lib/activity-import'; +import { + type ActivityImportResult, + activityImportResultMessage, + importActivityUpload, +} from '../lib/activity-import'; import { errorMessage } from '../lib/errors'; import { downloadSessionFitArchive } from '../lib/fit-archive'; import { @@ -36,11 +40,18 @@ export function useSessionHistory( const [deleting, setDeleting] = useState(false); const [exporting, setExporting] = useState(false); const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState<{ + error?: string; + fileName: string; + result?: ActivityImportResult; + }>(); const [historyStatus, setHistoryStatus] = useState(''); const [highlightedSessionIds, setHighlightedSessionIds] = useState([]); const [error, setError] = useState(''); const [revision, setRevision] = useState(0); const deleteInProgress = useRef(false); + const importGeneration = useRef(0); + const historyOpen = useRef(open); const historyLoadGeneration = useRef(0); const historyInitialized = useRef(false); @@ -106,8 +117,15 @@ export function useSessionHistory( }, [rememberSelectedSession, selectSession] ); + useEffect( + () => () => { + importGeneration.current += 1; + }, + [] + ); useEffect(() => { + historyOpen.current = open; if (!open) { historyLoadGeneration.current += 1; historyInitialized.current = false; @@ -129,31 +147,59 @@ export function useSessionHistory( .catch((loadError: unknown) => setError(errorMessage(loadError))); }, [loadHistory, open, preferredSessionId]); + const clearImportResult = useCallback(() => setImportResult(undefined), []); + + const refreshImportedHistory = useCallback( + async (result: ActivityImportResult, generation: number) => { + const newestImported = result.importedSessions.reduce( + (newest, session) => + !newest || session.endedAt > newest.endedAt ? session : newest, + undefined + ); + if (!(newestImported && historyOpen.current)) { + return; + } + try { + await loadHistory(newestImported.id, true); + } catch (loadError) { + if (generation === importGeneration.current) { + setError(errorMessage(loadError)); + } + } + }, + [loadHistory] + ); + const importActivityFile = useCallback( async (file: File) => { + const generation = importGeneration.current + 1; + importGeneration.current = generation; setImporting(true); + setImportResult(undefined); setHistoryStatus(''); setHighlightedSessionIds([]); try { const result = await importActivityUpload(file); + if (generation !== importGeneration.current) { + return; + } setHistoryStatus(activityImportResultMessage(result)); setHighlightedSessionIds(result.importedSessions.map((session) => session.id)); - const newestImported = result.importedSessions.reduce( - (newest, session) => - !newest || session.endedAt > newest.endedAt ? session : newest, - undefined - ); - if (newestImported) { - await loadHistory(newestImported.id, true); + await refreshImportedHistory(result, generation); + if (generation === importGeneration.current && result.failures.length > 0) { + setImportResult({ fileName: file.name, result }); } - setError(''); } catch (importError) { - setError(errorMessage(importError)); + if (generation === importGeneration.current) { + setImportResult({ error: errorMessage(importError), fileName: file.name }); + } } finally { - setImporting(false); + if (generation === importGeneration.current) { + setImporting(false); + } } }, - [loadHistory] + [refreshImportedHistory] ); const downloadAllActivityFiles = useCallback(async (format: ActivityFileFormat) => { @@ -224,6 +270,7 @@ export function useSessionHistory( }, [summaries]); return { + clearImportResult, combinedJourney, deleteSelectedSession, deleting, @@ -234,6 +281,7 @@ export function useSessionHistory( historyStatus, importActivityFile, importing, + importResult, loading, loadMore, revision, diff --git a/src/lib/activity-import.ts b/src/lib/activity-import.ts index 1fea792..907f732 100644 --- a/src/lib/activity-import.ts +++ b/src/lib/activity-import.ts @@ -1,4 +1,4 @@ -import { strFromU8, unzip } from 'fflate'; +import { strFromU8, Unzip, UnzipInflate, UnzipPassThrough } from 'fflate'; import type { SavedSession, WorkoutCourse } from '../types'; import { ACTIVITY_FILE_FORMAT, @@ -14,9 +14,9 @@ import { WORKOUT_COURSES } from './workouts'; const ACTIVITY_FILE_EXTENSION = /\.(fit|tcx)$/i; const ZIP_FILE_EXTENSION = /\.zip$/i; -const MAX_ACTIVITY_FILES_PER_IMPORT = 500; -const MAX_ACTIVITY_FILE_BYTES = 20 * 1024 * 1024; -const MAX_ACTIVITY_ARCHIVE_BYTES = 100 * 1024 * 1024; +const INPUT_CHUNK_BYTES = 64 * 1024; +const ZIP_END_RECORD_BYTES = 22; +const ZIP_MAX_COMMENT_BYTES = 0xff_ff; interface NamedActivityFile { contents: Uint8Array; @@ -24,6 +24,13 @@ interface NamedActivityFile { name: string; } +interface BufferedArchiveActivity { + chunks: Uint8Array[]; + format: ActivityFileFormat; + name: string; + size: number; +} + interface ImportDependencies { listSessions: () => Promise; listWorkoutCourses?: () => readonly WorkoutCourse[]; @@ -59,70 +66,179 @@ function formatForFilename(filename: string): ActivityFileFormat | undefined { } } -function unzipArchive(data: Uint8Array): Promise> { - let activityFileCount = 0; - let totalBytes = 0; - let limitExceeded = false; - return new Promise((resolve, reject) => { - unzip( - data, - { - filter: (file) => { - if (!formatForFilename(file.name)) { - return false; - } - activityFileCount += 1; - totalBytes += file.originalSize; - limitExceeded = - activityFileCount > MAX_ACTIVITY_FILES_PER_IMPORT || - file.originalSize > MAX_ACTIVITY_FILE_BYTES || - totalBytes > MAX_ACTIVITY_ARCHIVE_BYTES; - return !limitExceeded; - }, - }, - (error, files) => { - if (error) { - reject(error); - return; - } - if (limitExceeded) { - reject( - new Error('The ZIP contains too many or excessively large activity files.') - ); - return; - } - resolve(files); - } - ); - }); +function invalidArchive(): Error { + return new Error('The ZIP is incomplete or invalid. Export it again and retry.'); +} + +async function archiveEntryCount(file: File): Promise { + // Unzip only reads local records and accepts a missing central directory. + // Check the end record separately without loading the compressed archive. + const tailOffset = Math.max(0, file.size - ZIP_END_RECORD_BYTES - ZIP_MAX_COMMENT_BYTES - 20); + const tail = new DataView(await file.slice(tailOffset).arrayBuffer()); + let end = tail.byteLength - ZIP_END_RECORD_BYTES; + for (; end >= 0; end -= 1) { + if ( + tail.getUint32(end, true) === 0x06_05_4b_50 && + end + ZIP_END_RECORD_BYTES + tail.getUint16(end + 20, true) === tail.byteLength + ) { + break; + } + } + if ( + end < 0 || + tail.getUint16(end + 4, true) !== 0 || + tail.getUint16(end + 6, true) !== 0 || + tail.getUint16(end + 8, true) !== tail.getUint16(end + 10, true) + ) { + throw invalidArchive(); + } + let count = tail.getUint16(end + 10, true); + let directorySize = tail.getUint32(end + 12, true); + let directoryOffset = tail.getUint32(end + 16, true); + let directoryEnd = tailOffset + end; + if (end >= 20 && tail.getUint32(end - 20, true) === 0x07_06_4b_50) { + const zip64Offset = Number(tail.getBigUint64(end - 12, true)); + if ( + !Number.isSafeInteger(zip64Offset) || + tail.getUint32(end - 16, true) !== 0 || + tail.getUint32(end - 4, true) !== 1 || + zip64Offset + 56 > directoryEnd - 20 + ) { + throw invalidArchive(); + } + const zip64 = new DataView(await file.slice(zip64Offset, zip64Offset + 56).arrayBuffer()); + if ( + zip64.byteLength !== 56 || + zip64.getUint32(0, true) !== 0x06_06_4b_50 || + zip64.getUint32(16, true) !== 0 || + zip64.getUint32(20, true) !== 0 || + zip64.getBigUint64(24, true) !== zip64.getBigUint64(32, true) + ) { + throw invalidArchive(); + } + count = Number(zip64.getBigUint64(32, true)); + directorySize = Number(zip64.getBigUint64(40, true)); + directoryOffset = Number(zip64.getBigUint64(48, true)); + directoryEnd = zip64Offset; + } + if ( + !( + Number.isSafeInteger(count) && + Number.isSafeInteger(directorySize) && + Number.isSafeInteger(directoryOffset) + ) || + directoryOffset + directorySize !== directoryEnd || + directorySize < count * 46 + ) { + throw invalidArchive(); + } + return count; } -async function uploadedActivityFiles(file: File): Promise { +function completeArchiveActivity(activity: BufferedArchiveActivity): NamedActivityFile { + const [firstChunk] = activity.chunks; + let contents: Uint8Array; + if (activity.chunks.length === 1 && firstChunk) { + contents = firstChunk; + } else { + contents = new Uint8Array(activity.size); + let position = 0; + for (const chunk of activity.chunks) { + contents.set(chunk, position); + position += chunk.byteLength; + } + } + activity.chunks.length = 0; + return { contents, format: activity.format, name: activity.name }; +} + +async function* uploadedActivityFiles(file: File): AsyncGenerator { const directFormat = formatForFilename(file.name); if (directFormat) { - if (file.size > MAX_ACTIVITY_FILE_BYTES) { - throw new Error('The activity file is too large to import.'); + const contents = new Uint8Array(file.size); + for (let offset = 0; offset < file.size; offset += INPUT_CHUNK_BYTES) { + contents.set( + new Uint8Array(await file.slice(offset, offset + INPUT_CHUNK_BYTES).arrayBuffer()), + offset + ); } - return [ - { - contents: new Uint8Array(await file.arrayBuffer()), - format: directFormat, - name: file.name, - }, - ]; + yield { contents, format: directFormat, name: file.name }; + return; } if (!ZIP_FILE_EXTENSION.test(file.name)) { throw new Error('Choose a .fit or .tcx file, or a .zip containing activity files.'); } - const files = await unzipArchive(new Uint8Array(await file.arrayBuffer())); - const entries = Object.entries(files).flatMap(([name, contents]) => { - const format = formatForFilename(name); - return format ? [{ contents, format, name }] : []; + const expectedEntryCount = await archiveEntryCount(file); + const completed: BufferedArchiveActivity[] = []; + let entryCount = 0; + let activityFileCount = 0; + let unfinishedActivities = 0; + let archiveError: unknown; + class SkipZipEntry extends UnzipPassThrough {} + const archive = new Unzip((entry) => { + entryCount += 1; + const format = formatForFilename(entry.name); + if (!format) { + // Not starting an entry makes fflate retain its compressed chunks. + // Pass them straight to a discard callback instead of inflating them. + SkipZipEntry.compression = entry.compression; + archive.register(SkipZipEntry); + entry.ondata = () => undefined; + entry.start(); + return; + } + archive.register(UnzipPassThrough); + archive.register(UnzipInflate); + activityFileCount += 1; + unfinishedActivities += 1; + const activity: BufferedArchiveActivity = { chunks: [], format, name: entry.name, size: 0 }; + entry.ondata = (error, data, final) => { + if (error) { + archiveError ??= error; + } + if (archiveError) { + activity.chunks.length = 0; + return; + } + activity.chunks.push(data); + activity.size += data.byteLength; + if (final) { + if (entry.originalSize !== undefined && activity.size !== entry.originalSize) { + archiveError = invalidArchive(); + activity.chunks.length = 0; + return; + } + unfinishedActivities -= 1; + completed.push(activity); + } + }; + entry.start(); }); - if (entries.length === 0) { + for (let offset = 0; offset < file.size; offset += INPUT_CHUNK_BYTES) { + try { + const chunk = new Uint8Array( + await file.slice(offset, offset + INPUT_CHUNK_BYTES).arrayBuffer() + ); + archive.push(chunk, offset + chunk.byteLength === file.size); + } catch (error) { + archiveError ??= error; + } + // Drain this input chunk before reading more. Only the entry currently + // inflating and entries completed by this chunk retain their raw data. + for (const activity of completed) { + yield completeArchiveActivity(activity); + } + completed.length = 0; + if (archiveError) { + throw archiveError; + } + } + if (entryCount !== expectedEntryCount || unfinishedActivities !== 0) { + throw invalidArchive(); + } + if (activityFileCount === 0) { throw new Error('The ZIP contains no FIT or TCX activity files.'); } - return entries; } function parseActivityFile( @@ -143,36 +259,45 @@ export async function importActivityUpload( file: File, dependencies: ImportDependencies = DEFAULT_IMPORT_DEPENDENCIES ): Promise { - const activityFiles = await uploadedActivityFiles(file); + const activityFiles = uploadedActivityFiles(file); + let nextActivityFile = await activityFiles.next(); const importedAt = Date.now(); const savedSessions = await dependencies.listSessions(); const workoutCourses = dependencies.listWorkoutCourses?.() ?? []; const savedIds = new Set(savedSessions.map((session) => session.id)); const savedFingerprints = new Set(savedSessions.map(sessionImportFingerprint)); const result: ActivityImportResult = { - activityFileCount: activityFiles.length, + activityFileCount: 0, duplicateCount: 0, failures: [], importedSessions: [], }; - for (const activityFile of activityFiles) { - try { - const sessions = await parseActivityFile(activityFile, workoutCourses); - for (const session of sessions) { - const fingerprint = sessionImportFingerprint(session); - if (savedIds.has(session.id) || savedFingerprints.has(fingerprint)) { - result.duplicateCount += 1; - continue; + try { + while (!nextActivityFile.done) { + const activityFile = nextActivityFile.value; + result.activityFileCount += 1; + try { + const sessions = await parseActivityFile(activityFile, workoutCourses); + for (const session of sessions) { + const fingerprint = sessionImportFingerprint(session); + if (savedIds.has(session.id) || savedFingerprints.has(fingerprint)) { + result.duplicateCount += 1; + continue; + } + const importedSession = { ...session, importedAt }; + await dependencies.saveSession(importedSession); + savedIds.add(session.id); + savedFingerprints.add(fingerprint); + result.importedSessions.push(importedSession); } - const importedSession = { ...session, importedAt }; - await dependencies.saveSession(importedSession); - savedIds.add(session.id); - savedFingerprints.add(fingerprint); - result.importedSessions.push(importedSession); + } catch (error) { + result.failures.push({ fileName: activityFile.name, message: errorMessage(error) }); } - } catch (error) { - result.failures.push({ fileName: activityFile.name, message: errorMessage(error) }); + nextActivityFile = await activityFiles.next(); } + } catch (error) { + // A later ZIP read/decode failure must not hide sessions already saved. + result.failures.push({ fileName: file.name, message: errorMessage(error) }); } return result; } diff --git a/tests/tcx-import.test.ts b/tests/tcx-import.test.ts index f21d840..39adc53 100644 --- a/tests/tcx-import.test.ts +++ b/tests/tcx-import.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from 'bun:test'; import { DOMParser } from '@xmldom/xmldom'; -import { strToU8, zipSync } from 'fflate'; -import { activityImportResultMessage, importActivityUpload } from '../src/lib/activity-import'; +import { strToU8, Zip, ZipDeflate, zipSync } from 'fflate'; +import { importActivityUpload } from '../src/lib/activity-import'; import { CONTROL_MODE } from '../src/lib/control-mode'; import { sessionToFit } from '../src/lib/fit'; import { sessionToTcx } from '../src/lib/tcx'; +import { createSessionTcxArchive } from '../src/lib/tcx-archive'; import { parseTcxSessions } from '../src/lib/tcx-import'; import { WORKOUT_COURSES, workoutTerrainAtDistance } from '../src/lib/workouts'; import type { SavedSession } from '../src/types'; @@ -176,27 +177,108 @@ describe('TCX import', () => { expect(result.importedSessions[0]?.importedAt).toBeNumber(); expect(result.duplicateCount).toBe(1); expect(result.failures).toHaveLength(0); - expect(activityImportResultMessage(result)).toBe( - 'Imported 1 session · 1 duplicate skipped' - ); }); - test('reports invalid files without preventing valid ZIP entries from importing', async () => { - const archive = zipSync({ - 'broken.tcx': strToU8(''), - 'valid.tcx': strToU8(sessionToTcx(session)), + test('reports malformed activities in streaming ZIP entries without losing valid sessions', async () => { + const chunks: Uint8Array[] = []; + const archive = new Zip((error, data) => { + if (error) { + throw error; + } + chunks.push(data); }); - const result = await importActivityUpload(new File([archive], 'rides.zip'), { + for (const [name, contents] of [ + ['notes/ignored.txt', 'Not an activity'], + ['broken.tcx', ''], + ['nested/valid.tcx', sessionToTcx(session)], + ] as const) { + const entry = new ZipDeflate(name); + archive.add(entry); + entry.push(strToU8(contents), true); + } + archive.end(); + const saved: SavedSession[] = []; + const result = await importActivityUpload(new File(chunks, 'rides.zip'), { listSessions: () => Promise.resolve([]), - saveSession: () => Promise.resolve(), + saveSession: (imported) => { + saved.push(imported); + return Promise.resolve(); + }, }); - expect(result.importedSessions).toHaveLength(1); - expect(result.failures).toEqual([ + expect(result.activityFileCount).toBe(2); + expect(saved.map((imported) => imported.id)).toEqual([session.id]); + expect(result.importedSessions).toEqual(saved); + expect(result.failures.map((failure) => failure.fileName)).toEqual(['broken.tcx']); + }); + + test('restores a complete Ride Control export containing more than 500 sessions', async () => { + const sessions = Array.from({ length: 501 }, (_, index) => ({ + ...session, + endedAt: session.endedAt + index * 86_400_000, + id: `exported-session-${index}`, + startedAt: session.startedAt + index * 86_400_000, + })); + const archive = await createSessionTcxArchive(sessions); + const saved = new Map(); + const result = await importActivityUpload( + new File([new Uint8Array(archive)], 'full-export.zip'), { - fileName: 'broken.tcx', - message: 'The file is not a Training Center XML document.', + listSessions: () => Promise.resolve([]), + saveSession: (imported) => { + saved.set(imported.id, imported); + return Promise.resolve(); + }, + } + ); + expect([...saved.keys()]).toEqual(sessions.map((exported) => exported.id)); + expect(result.importedSessions).toEqual([...saved.values()]); + expect(result.activityFileCount).toBe(501); + expect(result.duplicateCount).toBe(0); + expect(result.failures).toEqual([]); + }); + + test('rejects a ZIP missing its end record even when its activity data is complete', async () => { + const archive = zipSync({ 'valid.tcx': strToU8(sessionToTcx(session)) }); + const saved: SavedSession[] = []; + await expect( + importActivityUpload(new File([archive.subarray(0, -22)], 'truncated.zip'), { + listSessions: () => Promise.resolve([]), + saveSession: (imported) => { + saved.push(imported); + return Promise.resolve(); + }, + }) + ).rejects.toThrow(); + expect(saved).toEqual([]); + }); + + test('reports corrupt ZIP data without hiding earlier successfully saved entries', async () => { + const tcx = strToU8(sessionToTcx(session)); + const archive = zipSync({ + 'first-valid.tcx': [tcx, { level: 0 }], + 'second-broken.tcx': tcx, + }); + const headers = new DataView(archive.buffer, archive.byteOffset, archive.byteLength); + const secondHeader = + 30 + headers.getUint16(26, true) + headers.getUint16(28, true) + tcx.byteLength; + const compressedData = + secondHeader + + 30 + + headers.getUint16(secondHeader + 26, true) + + headers.getUint16(secondHeader + 28, true); + // DEFLATE block type 3 is invalid; the surrounding ZIP remains intact. + archive[compressedData] = 0b111; + const saved: SavedSession[] = []; + const result = await importActivityUpload(new File([archive], 'corrupt.zip'), { + listSessions: () => Promise.resolve([]), + saveSession: (imported) => { + saved.push(imported); + return Promise.resolve(); }, - ]); + }); + expect(saved.map((imported) => imported.id)).toEqual([session.id]); + expect(result.importedSessions).toEqual(saved); + expect(result.failures.map((failure) => failure.fileName)).toEqual(['corrupt.zip']); }); test('imports mixed FIT and TCX archives and detects cross-format duplicates', async () => { @@ -233,13 +315,9 @@ describe('TCX import', () => { expect(saveCount).toBe(0); }); - test('rejects unsupported uploads and ZIP files without TCX entries', async () => { - await expect(importActivityUpload(new File(['no'], 'ride.gpx'))).rejects.toThrow( - 'Choose a .fit or .tcx file, or a .zip containing activity files.' - ); + test('rejects unsupported uploads and ZIP files without activity entries', async () => { + await expect(importActivityUpload(new File(['no'], 'ride.gpx'))).rejects.toThrow(); const archive = zipSync({ 'readme.txt': strToU8('nothing here') }); - await expect(importActivityUpload(new File([archive], 'rides.zip'))).rejects.toThrow( - 'The ZIP contains no FIT or TCX activity files.' - ); + await expect(importActivityUpload(new File([archive], 'rides.zip'))).rejects.toThrow(); }); });