diff --git a/apps/misc/src/app/dump_track.spec.ts b/apps/misc/src/app/dump_track.spec.ts new file mode 100644 index 00000000..685d91e8 --- /dev/null +++ b/apps/misc/src/app/dump_track.spec.ts @@ -0,0 +1,209 @@ +import { diffEncodeTrack, protos } from '@flyxc/common'; +import { describe, expect, it } from 'vitest'; + +import { createDefaultFileName, flightToCsv, formatUtcTime, parseArgs } from './dump_track'; + +describe('dump_track', () => { + describe('formatUtcTime', () => { + it('formats epoch 0 as 00:00:00', () => { + expect(formatUtcTime(0)).toBe('00:00:00'); + }); + + it('formats UTC time correctly', () => { + // 2023-11-15T14:05:09Z + const date = new Date(Date.UTC(2023, 10, 15, 14, 5, 9)); + const sec = Math.floor(date.getTime() / 1000); + expect(formatUtcTime(sec)).toBe('14:05:09'); + }); + + it('pads single-digit hours, minutes, and seconds', () => { + // 1:02:03 UTC on 1970-01-01 + const sec = 1 * 3600 + 2 * 60 + 3; + expect(formatUtcTime(sec)).toBe('01:02:03'); + }); + }); + + describe('createDefaultFileName', () => { + it('generates filename from pilot name and timestamp in YYYYMMDD format', () => { + // 2024-05-18T10:00:00Z + const date = new Date(Date.UTC(2024, 4, 18, 10, 0, 0)); + const sec = Math.floor(date.getTime() / 1000); + expect(createDefaultFileName('Victor Berchet', sec)).toBe('Victor Berchet-20240518.csv'); + }); + + it('defaults to "pilot" when pilot name is missing or empty', () => { + const date = new Date(Date.UTC(2024, 0, 5, 12, 0, 0)); + const sec = Math.floor(date.getTime() / 1000); + expect(createDefaultFileName(undefined, sec)).toBe('pilot-20240105.csv'); + expect(createDefaultFileName('', sec)).toBe('pilot-20240105.csv'); + }); + + it('sanitizes illegal path characters in pilot name', () => { + const date = new Date(Date.UTC(2024, 8, 30, 0, 0, 0)); + const sec = Math.floor(date.getTime() / 1000); + expect(createDefaultFileName('John/Doe:Test', sec)).toBe('John_Doe_Test-20240930.csv'); + }); + + it('uses fallback date if timestamp is missing', () => { + const fallback = new Date(Date.UTC(2023, 5, 20)); + expect(createDefaultFileName('Pilot', undefined, fallback)).toBe('Pilot-20230620.csv'); + }); + + it('generates epoch date 19700101 when timestamp is 0', () => { + expect(createDefaultFileName('Pilot', 0)).toBe('Pilot-19700101.csv'); + }); + + it('falls back to fallbackDate when timestamp is non-finite or invalid', () => { + const fallback = new Date(Date.UTC(2023, 5, 20)); + expect(createDefaultFileName('Pilot', NaN, fallback)).toBe('Pilot-20230620.csv'); + expect(createDefaultFileName('Pilot', Infinity, fallback)).toBe('Pilot-20230620.csv'); + expect(createDefaultFileName('Pilot', -Infinity, fallback)).toBe('Pilot-20230620.csv'); + expect(createDefaultFileName('Pilot', 1e20, fallback)).toBe('Pilot-20230620.csv'); + }); + + it('falls back to current date when fallbackDate is invalid', () => { + const invalidDate = new Date('invalid'); + const now = new Date(); + const expectedYyyy = now.getUTCFullYear(); + const result = createDefaultFileName('Pilot', undefined, invalidDate); + expect(result).toMatch(new RegExp(`^Pilot-${expectedYyyy}\\d{4}\\.csv$`)); + }); + }); + + describe('flightToCsv', () => { + it('outputs header line for empty track group', () => { + const emptyTrackGroup = protos.TrackGroup.toBinary({ tracks: [] }); + const { csv, numPositions } = flightToCsv(emptyTrackGroup); + expect(csv).toBe('latitude,longitude,altitude,time,timestamp'); + expect(numPositions).toBe(0); + }); + + it('decodes single track with positions into csv and generates default filename', () => { + const timestamp = Math.floor(Date.UTC(2023, 6, 12, 11, 0, 0) / 1000); + const rawTrack: protos.Track = { + pilot: 'Alice', + lat: [45.12345, 45.12355], + lon: [6.12345, 6.12355], + alt: [1200, 1250], + timeSec: [timestamp, timestamp + 60], + }; + + const encodedTrack = diffEncodeTrack(rawTrack); + const trackGroupBin = protos.TrackGroup.toBinary({ tracks: [encodedTrack] }); + + const { csv, defaultFileName, numPositions } = flightToCsv(trackGroupBin); + const lines = csv.split('\n'); + + expect(defaultFileName).toBe('Alice-20230712.csv'); + expect(numPositions).toBe(2); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe('latitude,longitude,altitude,time,timestamp'); + + const expectedTime1 = formatUtcTime(timestamp); + const expectedTime2 = formatUtcTime(timestamp + 60); + + expect(lines[1]).toBe(`45.12345,6.12345,1200,${expectedTime1},${timestamp}`); + expect(lines[2]).toBe(`45.12355,6.12355,1250,${expectedTime2},${timestamp + 60}`); + }); + + it('decodes multiple tracks in a track group', () => { + const timestamp = Math.floor(Date.UTC(2023, 0, 1, 10, 0, 0) / 1000); + const track1: protos.Track = diffEncodeTrack({ + pilot: 'Bob', + lat: [45.0], + lon: [6.0], + alt: [1000], + timeSec: [timestamp], + }); + const track2: protos.Track = diffEncodeTrack({ + pilot: 'Bob', + lat: [46.0], + lon: [7.0], + alt: [2000], + timeSec: [timestamp + 100], + }); + + const trackGroupBin = protos.TrackGroup.toBinary({ tracks: [track1, track2] }); + const { csv, defaultFileName, numPositions } = flightToCsv(trackGroupBin); + const lines = csv.split('\n'); + + expect(defaultFileName).toBe('Bob-20230101.csv'); + expect(numPositions).toBe(2); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe('latitude,longitude,altitude,time,timestamp'); + expect(lines[1]).toBe(`45,6,1000,${formatUtcTime(timestamp)},${timestamp}`); + expect(lines[2]).toBe(`46,7,2000,${formatUtcTime(timestamp + 100)},${timestamp + 100}`); + }); + + it('aggregates multiple track group buffers into a single csv', () => { + const timestamp1 = Math.floor(Date.UTC(2023, 5, 10, 8, 0, 0) / 1000); + const timestamp2 = Math.floor(Date.UTC(2023, 5, 10, 9, 0, 0) / 1000); + + const group1 = protos.TrackGroup.toBinary({ + tracks: [ + diffEncodeTrack({ + pilot: 'Pilot 1', + lat: [45.1], + lon: [6.1], + alt: [1500], + timeSec: [timestamp1], + }), + ], + }); + + const group2 = protos.TrackGroup.toBinary({ + tracks: [ + diffEncodeTrack({ + pilot: 'Pilot 2', + lat: [45.2], + lon: [6.2], + alt: [1600], + timeSec: [timestamp2], + }), + ], + }); + + const { csv, defaultFileName, numPositions } = flightToCsv([group1, group2], ['101', '102']); + const lines = csv.split('\n'); + + expect(defaultFileName).toBe('Pilot 1-20230610.csv'); + expect(numPositions).toBe(2); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe('latitude,longitude,altitude,time,timestamp'); + expect(lines[1]).toBe(`45.1,6.1,1500,${formatUtcTime(timestamp1)},${timestamp1}`); + expect(lines[2]).toBe(`45.2,6.2,1600,${formatUtcTime(timestamp2)},${timestamp2}`); + }); + }); + + describe('parseArgs', () => { + it('parses single track ID without output path', () => { + const { ids, outputPath } = parseArgs(['12345']); + expect(ids).toEqual(['12345']); + expect(outputPath).toBeUndefined(); + }); + + it('parses single track ID with output path', () => { + const { ids, outputPath } = parseArgs(['12345', 'flight.csv']); + expect(ids).toEqual(['12345']); + expect(outputPath).toBe('flight.csv'); + }); + + it('parses multiple track IDs with stdout output path', () => { + const { ids, outputPath } = parseArgs(['123', '456', '-']); + expect(ids).toEqual(['123', '456']); + expect(outputPath).toBe('-'); + }); + + it('parses query string containing multiple track IDs', () => { + const { ids, outputPath } = parseArgs(['id=123&id=456&id=789']); + expect(ids).toEqual(['123', '456', '789']); + expect(outputPath).toBeUndefined(); + }); + + it('returns empty ids when only nonnumeric argument is provided', () => { + const { ids, outputPath } = parseArgs(['invalid_filename.csv']); + expect(ids).toEqual([]); + expect(outputPath).toBe('invalid_filename.csv'); + }); + }); +}); diff --git a/apps/misc/src/app/dump_track.ts b/apps/misc/src/app/dump_track.ts new file mode 100644 index 00000000..469ec110 --- /dev/null +++ b/apps/misc/src/app/dump_track.ts @@ -0,0 +1,216 @@ +import { writeFileSync } from 'node:fs'; + +import type { RuntimeTrack } from '@flyxc/common'; +import { createTrackId, protos, protoToRuntimeTrack, round } from '@flyxc/common'; +import { getDatastore, retrieveTrackById } from '@flyxc/common-node'; + +/** + * Formats a timestamp in seconds to UTC time string (hh:mm:ss). + * + * @param timestampSec - Epoch time in seconds. + * @returns Time string in hh:mm:ss UTC. + */ +export function formatUtcTime(timestampSec: number): string { + const date = new Date(timestampSec * 1000); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + const seconds = String(date.getUTCSeconds()).padStart(2, '0'); + return `${hours}:${minutes}:${seconds}`; +} + +/** + * Creates default file name in the format: -YYYYMMDD.csv + * + * @param pilot - Pilot name (the `name` property of the runtime track). + * @param timestampSec - Epoch time in seconds of the flight start. + * @param fallbackDate - Fallback date if timestamp is missing. + * @returns Generated file name. + */ +export function createDefaultFileName(pilot?: string, timestampSec?: number, fallbackDate?: Date): string { + const sanitizedPilot = (pilot?.trim() || 'pilot').replace(/[/\\?%*:|"<>]/g, '_'); + let date: Date | undefined; + + if (timestampSec != null && Number.isFinite(timestampSec)) { + const candidate = new Date(timestampSec * 1000); + if (!Number.isNaN(candidate.getTime())) { + date = candidate; + } + } + + if (!date) { + date = fallbackDate != null && !Number.isNaN(fallbackDate.getTime()) ? fallbackDate : new Date(); + } + + const yyyy = date.getUTCFullYear(); + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + return `${sanitizedPilot}-${yyyy}${mm}${dd}.csv`; +} + +/** + * Decodes TrackGroup binary buffers into RuntimeTracks and converts all positions into CSV format. + * + * Header: + * latitude,longitude,altitude,time,timestamp + * + * @param trackGroupBins - Raw binary TrackGroup protobuf buffer(s). + * @param trackId - Track entity id(s). + * @param fallbackDate - Fallback date for default file name generation. + * @returns Formatted CSV string, default file name, and number of positions. + */ +export function flightToCsv( + trackGroupBins: (Uint8Array | Buffer)[] | Uint8Array | Buffer, + trackId: number | string | (number | string)[] = 0, + fallbackDate?: Date, +): { csv: string; defaultFileName: string; numPositions: number } { + const bins = Array.isArray(trackGroupBins) ? trackGroupBins : [trackGroupBins]; + const ids = Array.isArray(trackId) ? trackId : [trackId]; + const lines: string[] = ['latitude,longitude,altitude,time,timestamp']; + let defaultFileName = createDefaultFileName(undefined, undefined, fallbackDate); + let numPositions = 0; + + const runtimeTracks: RuntimeTrack[] = []; + + for (let groupIdx = 0; groupIdx < bins.length; groupIdx++) { + const bin = bins[groupIdx]; + const baseId = ids[groupIdx] ?? ids[0] ?? groupIdx; + const trackGroup = protos.TrackGroup.fromBinary(new Uint8Array(bin)); + + for (let trackIdx = 0; trackIdx < trackGroup.tracks.length; trackIdx++) { + runtimeTracks.push( + protoToRuntimeTrack(createTrackId(Number(baseId), trackIdx), trackGroup.tracks[trackIdx], false), + ); + } + } + + if (runtimeTracks.length > 0) { + const firstTrack = runtimeTracks[0]; + defaultFileName = createDefaultFileName(firstTrack.name, firstTrack.minTimeSec, fallbackDate); + } + + for (const track of runtimeTracks) { + const numPoints = track.lat.length; + + for (let i = 0; i < numPoints; i++) { + const lat = round(track.lat[i], 6); + const lon = round(track.lon[i], 6); + const alt = Math.round(track.alt[i]); + const timeSec = Math.round(track.timeSec[i]); + const timeUtc = formatUtcTime(timeSec); + + lines.push(`${lat},${lon},${alt},${timeUtc},${timeSec}`); + numPositions++; + } + } + + return { + csv: lines.join('\n'), + defaultFileName, + numPositions, + }; +} + +/** + * Retrieves Track entities from Datastore by ID(s), decodes them, and outputs a single aggregated CSV. + * + * If no output path is specified, saves to `-YYYYMMDD.csv` where pilot name + * is the `name` property of the first runtime track. + * + * @param trackId - Datastore Track entity ID or array of IDs. + * @param outputPath - Optional path to write CSV output to, or '-' for stdout. + * @returns The CSV content and saved file path. + */ +export async function dumpTrack( + trackId: string | number | (string | number)[], + outputPath?: string, +): Promise<{ csv: string; filePath: string }> { + const ids = Array.isArray(trackId) ? trackId : [trackId]; + if (ids.length === 0) { + throw new Error('No track ID provided'); + } + + const datastore = getDatastore(); + const trackGroupBins: Buffer[] = []; + let fallbackDate: Date | undefined; + + for (const id of ids) { + const trackEntity = await retrieveTrackById(datastore, id); + if (!trackEntity) { + throw new Error(`Track ${id} not found in Datastore`); + } + if (!trackEntity.track_group) { + throw new Error(`Track ${id} does not have track_group data`); + } + trackGroupBins.push(trackEntity.track_group); + if (!fallbackDate && trackEntity.created) { + fallbackDate = trackEntity.created; + } + } + + const { csv, defaultFileName, numPositions } = flightToCsv(trackGroupBins, ids, fallbackDate); + + if (outputPath === '-') { + console.log(csv); + return { csv, filePath: '-' }; + } + + const targetFile = outputPath ?? defaultFileName; + writeFileSync(targetFile, csv, 'utf-8'); + console.log(`Saved ${numPositions} positions to ${targetFile}`); + + return { csv, filePath: targetFile }; +} + +/** + * Parses command-line arguments to extract track IDs and optional output path. + * + * @param args - CLI arguments. + * @returns Object with parsed IDs and optional outputPath. + */ +export function parseArgs(args: string[]): { ids: string[]; outputPath?: string } { + const ids: string[] = []; + let outputPath: string | undefined; + + for (const arg of args) { + if (arg.includes('id=')) { + const parsedIds = [...arg.matchAll(/(?:^|[?&])id=(\d+)/g)].map((m) => m[1]); + if (parsedIds.length > 0) { + ids.push(...parsedIds); + continue; + } + } + if (/^\d+$/.test(arg)) { + ids.push(arg); + } else { + outputPath = arg; + } + } + + return { ids, outputPath }; +} + +function printUsage(): void { + console.error('Usage: node dump_track.js [output_file]'); + console.error('If output_file is omitted, writes to -YYYYMMDD.csv (use "-" for stdout)'); +} + +if (process.env.NODE_ENV !== 'test') { + const args = process.argv.slice(2); + if (args.length === 0 || args.includes('-h') || args.includes('--help')) { + printUsage(); + process.exit(args.length === 0 ? 1 : 0); + } + + const { ids, outputPath } = parseArgs(args); + + if (ids.length === 0) { + console.error('Error: No valid track ID provided.\n'); + printUsage(); + process.exit(1); + } + + dumpTrack(ids, outputPath).catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + }); +} diff --git a/apps/misc/vite.config.ts b/apps/misc/vite.config.ts index f65d6c80..78c5abfc 100644 --- a/apps/misc/vite.config.ts +++ b/apps/misc/vite.config.ts @@ -64,6 +64,7 @@ export default defineConfig(({ mode }) => { list_trackers: path.resolve(__dirname, 'src/app/list_trackers.ts'), list_flymaster: path.resolve(__dirname, 'src/app/list_flymaster.ts'), email_inreach: path.resolve(__dirname, 'src/app/email_inreach.ts'), + dump_track: path.resolve(__dirname, 'src/app/dump_track.ts'), }, output: { entryFileNames: '[name].js',