41a2435cc2
Select button in header enters selection mode. Cards show a checkbox and become tappable. Header updates to show N selected + Cancel. Bulk actions (bottom bar): - Export: sequential Sharing.shareAsync for each selected file - Upload: sequential upload with N/total progress text - Delete: confirm then delete all selected at once - Merge: parses each GPX file, combines track points sorted by time, prompts for title via Modal, saves as new recording parseGpxFile() added to gpx.ts: reads file via expo-file-system, extracts trkpt elements including hr/power/cad extensions via regex.
86 lines
3.0 KiB
TypeScript
86 lines
3.0 KiB
TypeScript
import { File, Directory, Paths } from 'expo-file-system';
|
|
import { TrackPoint } from '../types';
|
|
|
|
function recordingsDir(): Directory {
|
|
return new Directory(Paths.document, 'recordings');
|
|
}
|
|
|
|
export function ensureRecordingsDir(): void {
|
|
const dir = recordingsDir();
|
|
if (!dir.exists) dir.create();
|
|
}
|
|
|
|
export function saveGpx(trackPoints: TrackPoint[], title: string): string {
|
|
ensureRecordingsDir();
|
|
const filename = `${sanitizeFilename(title)}_${Date.now()}.gpx`;
|
|
const file = new File(recordingsDir(), filename);
|
|
file.write(buildGpx(trackPoints, title));
|
|
return file.uri;
|
|
}
|
|
|
|
export function buildGpx(trackPoints: TrackPoint[], title: string): string {
|
|
const trkpts = trackPoints.map(buildTrkpt).join('\n');
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<gpx version="1.1" creator="bincio-rec"
|
|
xmlns="http://www.topografix.com/GPX/1/1"
|
|
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1">
|
|
<metadata><name>${escapeXml(title)}</name></metadata>
|
|
<trk>
|
|
<name>${escapeXml(title)}</name>
|
|
<trkseg>
|
|
${trkpts}
|
|
</trkseg>
|
|
</trk>
|
|
</gpx>`;
|
|
}
|
|
|
|
function buildTrkpt(pt: TrackPoint): string {
|
|
const ext = buildExtensions(pt);
|
|
return ` <trkpt lat="${pt.lat}" lon="${pt.lon}">
|
|
<ele>${pt.ele.toFixed(1)}</ele>
|
|
<time>${pt.time.toISOString()}</time>${ext}
|
|
</trkpt>`;
|
|
}
|
|
|
|
function buildExtensions(pt: TrackPoint): string {
|
|
if (pt.hr == null && pt.power == null && pt.cad == null) return '';
|
|
const hr = pt.hr != null ? `<gpxtpx:hr>${pt.hr}</gpxtpx:hr>` : '';
|
|
const power = pt.power != null ? `<gpxtpx:power>${pt.power}</gpxtpx:power>` : '';
|
|
const cad = pt.cad != null ? `<gpxtpx:cad>${pt.cad}</gpxtpx:cad>` : '';
|
|
return `
|
|
<extensions>
|
|
<gpxtpx:TrackPointExtension>
|
|
${hr}${power}${cad}
|
|
</gpxtpx:TrackPointExtension>
|
|
</extensions>`;
|
|
}
|
|
|
|
export async function parseGpxFile(fileUri: string): Promise<TrackPoint[]> {
|
|
const content = await new File(fileUri).text();
|
|
const points: TrackPoint[] = [];
|
|
const re = /<trkpt\s+lat="([^"]+)"\s+lon="([^"]+)">([\s\S]*?)<\/trkpt>/g;
|
|
let m;
|
|
while ((m = re.exec(content)) !== null) {
|
|
const lat = parseFloat(m[1]);
|
|
const lon = parseFloat(m[2]);
|
|
const inner = m[3];
|
|
const ele = parseFloat(inner.match(/<ele>([\d.-]+)<\/ele>/)?.[1] ?? '0');
|
|
const timeStr = inner.match(/<time>([^<]+)<\/time>/)?.[1] ?? '';
|
|
const hr = parseInt(inner.match(/<gpxtpx:hr>(\d+)<\/gpxtpx:hr>/)?.[1] ?? '') || undefined;
|
|
const power = parseInt(inner.match(/<gpxtpx:power>(\d+)<\/gpxtpx:power>/)?.[1] ?? '') || undefined;
|
|
const cad = parseInt(inner.match(/<gpxtpx:cad>(\d+)<\/gpxtpx:cad>/)?.[1] ?? '') || undefined;
|
|
if (!isNaN(lat) && !isNaN(lon)) {
|
|
points.push({ lat, lon, ele, time: new Date(timeStr), hr, power, cad });
|
|
}
|
|
}
|
|
return points;
|
|
}
|
|
|
|
function escapeXml(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function sanitizeFilename(s: string): string {
|
|
return s.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
|
}
|