feat: bulk operations in Saved tab (export, upload, delete, merge)
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.
This commit is contained in:
@@ -1,20 +1,54 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert, ActivityIndicator } from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import React, { useCallback, useLayoutEffect, useState } from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, FlatList, TouchableOpacity,
|
||||
Alert, ActivityIndicator, Modal, TextInput, Pressable,
|
||||
} from 'react-native';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { listRecordings, deleteRecording } from '../services/db';
|
||||
import { listRecordings, deleteRecording, insertRecording } from '../services/db';
|
||||
import { uploadGpx } from '../services/upload';
|
||||
import { saveGpx, parseGpxFile } from '../services/gpx';
|
||||
import { SavedRecording } from '../types';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { randomUUID } from 'expo-crypto';
|
||||
import { colors } from '../theme';
|
||||
import { useTheme } from '../ThemeContext';
|
||||
import { SPORT_ICONS, SPORT_LABELS, SUB_SPORT_LABELS, type Sport, type SubSport } from '../sports';
|
||||
|
||||
export function SavedRecordingsScreen() {
|
||||
const { accent } = useTheme();
|
||||
const [recordings, setRecordings] = useState<SavedRecording[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const nav = useNavigation();
|
||||
const { accent, accentDim } = useTheme();
|
||||
const [recordings, setRecordings] = useState<SavedRecording[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [busyMsg, setBusyMsg] = useState<string | null>(null);
|
||||
const [mergeVisible, setMergeVisible] = useState(false);
|
||||
const [mergeTitle, setMergeTitle] = useState('');
|
||||
|
||||
// ── Header button ────────────────────────────────────────────────────────
|
||||
|
||||
useLayoutEffect(() => {
|
||||
nav.setOptions(
|
||||
selectionMode
|
||||
? {
|
||||
headerTitle: `${selectedIds.size} selected`,
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={exitSelection} style={{ marginRight: 16 }}>
|
||||
<Text style={{ color: accent, fontSize: 15, fontWeight: '600' }}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}
|
||||
: {
|
||||
headerTitle: 'Saved',
|
||||
headerRight: () => (
|
||||
<TouchableOpacity onPress={enterSelection} style={{ marginRight: 16 }}>
|
||||
<Text style={{ color: accent, fontSize: 15, fontWeight: '600' }}>Select</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
},
|
||||
);
|
||||
}, [nav, selectionMode, selectedIds.size, accent]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -22,38 +56,132 @@ export function SavedRecordingsScreen() {
|
||||
}, []),
|
||||
);
|
||||
|
||||
// ── Selection helpers ────────────────────────────────────────────────────
|
||||
|
||||
function enterSelection() { setSelectionMode(true); setSelectedIds(new Set()); }
|
||||
function exitSelection() { setSelectionMode(false); setSelectedIds(new Set()); }
|
||||
|
||||
function toggleId(id: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const selected = recordings.filter((r) => selectedIds.has(r.id));
|
||||
|
||||
// ── Single-item actions (normal mode) ────────────────────────────────────
|
||||
|
||||
async function handleShare(rec: SavedRecording) {
|
||||
await Sharing.shareAsync(rec.filePath, { mimeType: 'application/gpx+xml', dialogTitle: rec.title });
|
||||
}
|
||||
|
||||
async function handleUpload(rec: SavedRecording) {
|
||||
const instanceUrl = await AsyncStorage.getItem('instanceUrl');
|
||||
const apiToken = await AsyncStorage.getItem('apiToken');
|
||||
if (!instanceUrl || !apiToken) {
|
||||
Alert.alert('Not connected', 'Log in to your bincio instance in Settings first.');
|
||||
return;
|
||||
}
|
||||
setUploading(rec.id);
|
||||
const [instanceUrl, apiToken] = await Promise.all([
|
||||
AsyncStorage.getItem('instanceUrl'), AsyncStorage.getItem('apiToken'),
|
||||
]);
|
||||
if (!instanceUrl || !apiToken) { Alert.alert('Not connected', 'Log in in Settings → Sync first.'); return; }
|
||||
const result = await uploadGpx(rec.filePath, instanceUrl, apiToken);
|
||||
setUploading(null);
|
||||
if (result.ok) Alert.alert('Uploaded', `"${rec.title}" uploaded successfully.`);
|
||||
if (result.ok) Alert.alert('Uploaded', `"${rec.title}" uploaded.`);
|
||||
else Alert.alert('Upload failed', result.error);
|
||||
}
|
||||
|
||||
function handleDelete(rec: SavedRecording) {
|
||||
Alert.alert('Delete recording?', `"${rec.title}" will be removed.`, [
|
||||
Alert.alert('Delete?', `"${rec.title}" will be removed.`, [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await deleteRecording(rec.id);
|
||||
setRecordings((prev) => prev.filter((r) => r.id !== rec.id));
|
||||
},
|
||||
},
|
||||
{ text: 'Delete', style: 'destructive', onPress: async () => {
|
||||
await deleteRecording(rec.id);
|
||||
setRecordings((prev) => prev.filter((r) => r.id !== rec.id));
|
||||
}},
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Bulk actions ─────────────────────────────────────────────────────────
|
||||
|
||||
async function handleBulkExport() {
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
setBusyMsg(`Exporting ${i + 1}/${selected.length}…`);
|
||||
await Sharing.shareAsync(selected[i].filePath, { mimeType: 'application/gpx+xml', dialogTitle: selected[i].title });
|
||||
}
|
||||
setBusyMsg(null);
|
||||
}
|
||||
|
||||
async function handleBulkUpload() {
|
||||
const [instanceUrl, apiToken] = await Promise.all([
|
||||
AsyncStorage.getItem('instanceUrl'), AsyncStorage.getItem('apiToken'),
|
||||
]);
|
||||
if (!instanceUrl || !apiToken) { Alert.alert('Not connected', 'Log in in Settings → Sync first.'); return; }
|
||||
let ok = 0, fail = 0;
|
||||
for (let i = 0; i < selected.length; i++) {
|
||||
setBusyMsg(`Uploading ${i + 1}/${selected.length}…`);
|
||||
const result = await uploadGpx(selected[i].filePath, instanceUrl, apiToken);
|
||||
result.ok ? ok++ : fail++;
|
||||
}
|
||||
setBusyMsg(null);
|
||||
Alert.alert('Upload complete', `${ok} uploaded${fail ? `, ${fail} failed` : ''}.`);
|
||||
}
|
||||
|
||||
function handleBulkDelete() {
|
||||
Alert.alert(
|
||||
`Delete ${selected.length} recording${selected.length !== 1 ? 's' : ''}?`,
|
||||
'This cannot be undone.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Delete', style: 'destructive', onPress: async () => {
|
||||
setBusyMsg('Deleting…');
|
||||
for (const rec of selected) await deleteRecording(rec.id);
|
||||
setRecordings((prev) => prev.filter((r) => !selectedIds.has(r.id)));
|
||||
setBusyMsg(null);
|
||||
exitSelection();
|
||||
}},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function openMergeModal() {
|
||||
const names = selected.map((r) => r.title).join(' + ');
|
||||
setMergeTitle(names.length > 60 ? names.slice(0, 60) + '…' : names);
|
||||
setMergeVisible(true);
|
||||
}
|
||||
|
||||
async function handleMergeConfirm() {
|
||||
if (!mergeTitle.trim()) { Alert.alert('Title required'); return; }
|
||||
setMergeVisible(false);
|
||||
setBusyMsg('Merging…');
|
||||
try {
|
||||
// Parse all GPX files and combine track points sorted by time
|
||||
const allPoints = (
|
||||
await Promise.all(selected.map((r) => parseGpxFile(r.filePath)))
|
||||
).flat().sort((a, b) => a.time.getTime() - b.time.getTime());
|
||||
|
||||
const sport = selected[0].sport;
|
||||
const subSport = selected[0].subSport;
|
||||
const filePath = saveGpx(allPoints, mergeTitle.trim());
|
||||
const merged: SavedRecording = {
|
||||
id: randomUUID(),
|
||||
title: mergeTitle.trim(),
|
||||
date: allPoints[0]?.time.toISOString() ?? new Date().toISOString(),
|
||||
durationSeconds: selected.reduce((s, r) => s + r.durationSeconds, 0),
|
||||
distanceMeters: selected.reduce((s, r) => s + r.distanceMeters, 0),
|
||||
filePath,
|
||||
sport,
|
||||
subSport,
|
||||
};
|
||||
await insertRecording(merged);
|
||||
const updated = await listRecordings();
|
||||
setRecordings(updated);
|
||||
setBusyMsg(null);
|
||||
exitSelection();
|
||||
Alert.alert('Merged', `"${merged.title}" saved with ${allPoints.length} track points.`);
|
||||
} catch (e: any) {
|
||||
setBusyMsg(null);
|
||||
Alert.alert('Merge failed', e?.message ?? 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const formatDuration = (secs: number) => {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
@@ -68,51 +196,143 @@ export function SavedRecordingsScreen() {
|
||||
data={recordings}
|
||||
keyExtractor={(r) => r.id}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardMeta}>
|
||||
<View style={styles.cardTitleRow}>
|
||||
<Text style={styles.cardIcon}>{SPORT_ICONS[item.sport as Sport] ?? '⚡'}</Text>
|
||||
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||
renderItem={({ item }) => {
|
||||
const isSelected = selectedIds.has(item.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={selectionMode ? 0.7 : 1}
|
||||
onPress={() => selectionMode && toggleId(item.id)}
|
||||
style={[styles.card, isSelected && { borderColor: accent, backgroundColor: accentDim }]}
|
||||
>
|
||||
<View style={styles.cardInner}>
|
||||
{/* Checkbox (selection mode only) */}
|
||||
{selectionMode && (
|
||||
<View style={[styles.checkbox, isSelected && { borderColor: accent, backgroundColor: accent }]}>
|
||||
{isSelected && <Text style={styles.checkmark}>✓</Text>}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Meta */}
|
||||
<View style={styles.cardMeta}>
|
||||
<View style={styles.cardTitleRow}>
|
||||
<Text style={styles.cardIcon}>{SPORT_ICONS[item.sport as Sport] ?? '⚡'}</Text>
|
||||
<Text style={styles.cardTitle} numberOfLines={1}>{item.title}</Text>
|
||||
</View>
|
||||
<Text style={styles.cardSub}>
|
||||
{SPORT_LABELS[item.sport as Sport] ?? item.sport}
|
||||
{item.subSport ? ` · ${SUB_SPORT_LABELS[item.subSport as SubSport] ?? item.subSport}` : ''}
|
||||
{' · '}{new Date(item.date).toLocaleDateString()}
|
||||
{' · '}{formatDuration(item.durationSeconds)}
|
||||
{' · '}{(item.distanceMeters / 1000).toFixed(2)} km
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.cardSub}>
|
||||
{SPORT_LABELS[item.sport as Sport] ?? item.sport}
|
||||
{item.subSport ? ` · ${SUB_SPORT_LABELS[item.subSport as SubSport] ?? item.subSport}` : ''}
|
||||
{' · '}{new Date(item.date).toLocaleDateString()}
|
||||
{' · '}{formatDuration(item.durationSeconds)}
|
||||
{' · '}{(item.distanceMeters / 1000).toFixed(2)} km
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.cardActions}>
|
||||
<TouchableOpacity onPress={() => handleShare(item)}>
|
||||
<Text style={[styles.action, { color: accent }]}>Export</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => handleUpload(item)} disabled={uploading === item.id}>
|
||||
<Text style={[styles.action, { color: accent }]}>{uploading === item.id ? '…' : 'Upload'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => handleDelete(item)}>
|
||||
<Text style={[styles.action, { color: colors.error }]}>Delete</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Per-card actions (normal mode only) */}
|
||||
{!selectionMode && (
|
||||
<View style={styles.cardActions}>
|
||||
<TouchableOpacity onPress={() => handleShare(item)}>
|
||||
<Text style={[styles.action, { color: accent }]}>Export</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => handleUpload(item)}>
|
||||
<Text style={[styles.action, { color: accent }]}>Upload</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => handleDelete(item)}>
|
||||
<Text style={[styles.action, { color: colors.error }]}>Delete</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
ListEmptyComponent={<Text style={styles.empty}>No recordings yet.</Text>}
|
||||
/>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selectionMode && (
|
||||
<View style={styles.actionBar}>
|
||||
{busyMsg ? (
|
||||
<View style={styles.busyRow}>
|
||||
<ActivityIndicator color={accent} />
|
||||
<Text style={[styles.busyText, { color: accent }]}>{busyMsg}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<BulkBtn label="Export" onPress={handleBulkExport} disabled={selected.length === 0} accent={accent} />
|
||||
<BulkBtn label="Upload" onPress={handleBulkUpload} disabled={selected.length === 0} accent={accent} />
|
||||
<BulkBtn label="Delete" onPress={handleBulkDelete} disabled={selected.length === 0} accent={colors.error} />
|
||||
<BulkBtn label="Merge" onPress={openMergeModal} disabled={selected.length < 2} accent={accent} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Merge title modal */}
|
||||
<Modal visible={mergeVisible} transparent animationType="fade" onRequestClose={() => setMergeVisible(false)}>
|
||||
<Pressable style={styles.modalOverlay} onPress={() => setMergeVisible(false)}>
|
||||
<Pressable style={styles.modalBox} onPress={() => {}}>
|
||||
<Text style={styles.modalHeading}>Merge {selected.length} recordings</Text>
|
||||
<TextInput
|
||||
style={styles.modalInput}
|
||||
value={mergeTitle}
|
||||
onChangeText={setMergeTitle}
|
||||
placeholder="Merged activity title"
|
||||
placeholderTextColor={colors.placeholder}
|
||||
autoFocus
|
||||
selectTextOnFocus
|
||||
/>
|
||||
<View style={styles.modalButtons}>
|
||||
<TouchableOpacity style={styles.modalBtn} onPress={() => setMergeVisible(false)}>
|
||||
<Text style={[styles.modalBtnText, { color: colors.textSub }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.modalBtn, { backgroundColor: accent }]} onPress={handleMergeConfirm}>
|
||||
<Text style={[styles.modalBtnText, { color: '#fff' }]}>Merge</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkBtn({ label, onPress, disabled, accent }: { label: string; onPress: () => void; disabled: boolean; accent: string }) {
|
||||
return (
|
||||
<TouchableOpacity style={[styles.bulkBtn, disabled && styles.bulkBtnDisabled]} onPress={onPress} disabled={disabled}>
|
||||
<Text style={[styles.bulkBtnText, { color: disabled ? colors.textMuted : accent }]}>{label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bg },
|
||||
center: { flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' },
|
||||
list: { padding: 16, gap: 10 },
|
||||
card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 12, padding: 16 },
|
||||
cardTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 },
|
||||
cardIcon: { fontSize: 18 },
|
||||
cardMeta: { marginBottom: 12 },
|
||||
cardTitle: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||
cardSub: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
|
||||
cardActions:{ flexDirection: 'row', gap: 20 },
|
||||
action: { fontWeight: '600', fontSize: 14 },
|
||||
empty: { color: colors.textMuted, textAlign: 'center', marginTop: 60 },
|
||||
container: { flex: 1, backgroundColor: colors.bg },
|
||||
center: { flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' },
|
||||
list: { padding: 16, gap: 10, paddingBottom: 24 },
|
||||
card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 12, padding: 14 },
|
||||
cardInner: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
||||
checkbox: { width: 22, height: 22, borderRadius: 11, borderWidth: 1.5, borderColor: colors.borderStrong, alignItems: 'center', justifyContent: 'center' },
|
||||
checkmark: { color: '#fff', fontSize: 13, fontWeight: '700' },
|
||||
cardMeta: { flex: 1 },
|
||||
cardTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 4 },
|
||||
cardIcon: { fontSize: 17 },
|
||||
cardTitle: { color: colors.text, fontSize: 15, fontWeight: '600', flex: 1 },
|
||||
cardSub: { color: colors.textMuted, fontSize: 12 },
|
||||
cardActions: { flexDirection: 'row', gap: 20, marginTop: 12, paddingTop: 12, borderTopWidth: 1, borderTopColor: colors.border },
|
||||
action: { fontWeight: '600', fontSize: 14 },
|
||||
empty: { color: colors.textMuted, textAlign: 'center', marginTop: 60 },
|
||||
// Action bar
|
||||
actionBar: { flexDirection: 'row', backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.border, paddingVertical: 12, paddingHorizontal: 8 },
|
||||
bulkBtn: { flex: 1, alignItems: 'center', paddingVertical: 8 },
|
||||
bulkBtnDisabled:{ opacity: 0.4 },
|
||||
bulkBtnText: { fontSize: 14, fontWeight: '600' },
|
||||
busyRow: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10 },
|
||||
busyText: { fontSize: 14, fontWeight: '500' },
|
||||
// Merge modal
|
||||
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', padding: 32 },
|
||||
modalBox: { backgroundColor: colors.surface, borderRadius: 14, padding: 20, gap: 16 },
|
||||
modalHeading: { color: colors.text, fontSize: 17, fontWeight: '700' },
|
||||
modalInput: { backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border, color: colors.text, borderRadius: 8, padding: 12, fontSize: 15 },
|
||||
modalButtons: { flexDirection: 'row', gap: 10 },
|
||||
modalBtn: { flex: 1, borderRadius: 8, padding: 12, alignItems: 'center', backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border },
|
||||
modalBtnText: { fontSize: 15, fontWeight: '600' },
|
||||
});
|
||||
|
||||
@@ -55,6 +55,27 @@ function buildExtensions(pt: TrackPoint): string {
|
||||
</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, '"');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user