0d2176aef0
When original_path is set (i.e. the original FIT/GPX/TCX is still on disk),
upload via POST /api/upload/raw { filename, base64 } so the server re-extracts
with DEM elevation correction. Falls back to /api/upload/bas (pre-extracted
BAS JSON) when original_path is null or the file has been deleted.
240 lines
8.0 KiB
TypeScript
240 lines
8.0 KiB
TypeScript
import * as FileSystem from 'expo-file-system/legacy';
|
|
import type { SQLiteDatabase } from 'expo-sqlite';
|
|
import { getSetting, upsertRemoteActivity } from './queries';
|
|
|
|
export type SyncResult = {
|
|
synced: number;
|
|
total: number;
|
|
fetched?: number;
|
|
uploaded?: number;
|
|
failed?: number;
|
|
error?: string;
|
|
};
|
|
|
|
async function resolveCredentials(db: SQLiteDatabase): Promise<{ instanceUrl: string; token: string } | { error: string }> {
|
|
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
|
|
const token = await getSetting(db, 'api_token');
|
|
if (!instanceUrl || !token) return { error: 'No instance configured — add one in Settings.' };
|
|
return { instanceUrl, token };
|
|
}
|
|
|
|
export async function downloadFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
|
const creds = await resolveCredentials(db);
|
|
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
|
const { instanceUrl, token } = creds;
|
|
|
|
let resp: Response;
|
|
try {
|
|
resp = await fetch(`${instanceUrl}/api/feed`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
} catch {
|
|
return { synced: 0, total: 0, error: 'Could not reach instance — check your connection.' };
|
|
}
|
|
|
|
if (resp.status === 401) return { synced: 0, total: 0, error: 'Session expired — reconnect in Settings.' };
|
|
if (!resp.ok) return { synced: 0, total: 0, error: `Server error (${resp.status})` };
|
|
|
|
const data: { activities?: RemoteSummary[] } = await resp.json();
|
|
const activities = data.activities ?? [];
|
|
const syncMode = (await getSetting(db, 'sync_mode')) ?? 'summaries';
|
|
|
|
let synced = 0;
|
|
for (const a of activities) {
|
|
const detailJson = JSON.stringify({
|
|
id: a.id,
|
|
title: a.title ?? a.id,
|
|
sport: a.sport ?? null,
|
|
started_at: a.started_at ?? null,
|
|
distance_m: a.distance_m ?? null,
|
|
moving_time_s: a.moving_time_s ?? null,
|
|
elevation_gain_m: a.elevation_gain_m ?? null,
|
|
avg_speed_kmh: a.avg_speed_kmh ?? null,
|
|
avg_hr_bpm: a.avg_hr_bpm ?? null,
|
|
avg_power_w: a.avg_power_w ?? null,
|
|
});
|
|
const changed = await upsertRemoteActivity(db, a.id, detailJson);
|
|
if (changed) synced++;
|
|
}
|
|
|
|
if (syncMode !== 'full') return { synced, total: activities.length };
|
|
|
|
// Full mode: fetch geojson + timeseries for activities missing them
|
|
const headers = { Authorization: `Bearer ${token}` };
|
|
let fetched = 0;
|
|
for (const a of activities) {
|
|
const row = db.getFirstSync<{ g: number; t: number }>(
|
|
'SELECT (geojson IS NOT NULL) as g, (timeseries_json IS NOT NULL) as t FROM activities WHERE id = ?',
|
|
[a.id],
|
|
);
|
|
if (row?.g && row?.t) continue;
|
|
|
|
let gj: string | null = null;
|
|
let ts: string | null = null;
|
|
try {
|
|
if (!row?.g) {
|
|
const r = await fetch(`${instanceUrl}/api/activity/${a.id}/geojson`, { headers });
|
|
if (r.ok) gj = await r.text();
|
|
}
|
|
if (!row?.t) {
|
|
const r = await fetch(`${instanceUrl}/api/activity/${a.id}/timeseries`, { headers });
|
|
if (r.ok) ts = await r.text();
|
|
}
|
|
} catch {}
|
|
|
|
if (gj !== null || ts !== null) {
|
|
await db.runAsync(
|
|
`UPDATE activities SET
|
|
geojson = COALESCE(geojson, ?),
|
|
timeseries_json = COALESCE(timeseries_json, ?)
|
|
WHERE id = ? AND origin = 'remote'`,
|
|
[gj, ts, a.id],
|
|
);
|
|
fetched++;
|
|
}
|
|
}
|
|
|
|
return { synced, total: activities.length, fetched };
|
|
}
|
|
|
|
export async function uploadFeed(
|
|
db: SQLiteDatabase,
|
|
onProgress?: (n: number, total: number) => void,
|
|
): Promise<SyncResult> {
|
|
const creds = await resolveCredentials(db);
|
|
if ('error' in creds) return { synced: 0, total: 0, error: creds.error };
|
|
const { instanceUrl, token } = creds;
|
|
|
|
// Reconcile local synced_at against what the server actually has.
|
|
// If the server was wiped/reset, activities we thought were uploaded need
|
|
// re-uploading — clear their synced_at so they re-enter the upload queue.
|
|
try {
|
|
const feedResp = await fetch(`${instanceUrl}/api/feed`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (feedResp.ok) {
|
|
const feedData: { activities?: { id: string }[] } = await feedResp.json();
|
|
const serverIds = new Set((feedData.activities ?? []).map(a => a.id));
|
|
const syncedRows = db.getAllSync<{ id: string }>(
|
|
`SELECT id FROM activities WHERE origin = 'local' AND synced_at IS NOT NULL`,
|
|
);
|
|
for (const row of syncedRows) {
|
|
if (!serverIds.has(row.id)) {
|
|
await db.runAsync(`UPDATE activities SET synced_at = NULL WHERE id = ?`, [row.id]);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Best-effort — proceed with upload even if reconciliation fails
|
|
}
|
|
|
|
const { uploaded, failed } = await uploadLocalActivities(db, instanceUrl, token, onProgress);
|
|
return { synced: 0, total: 0, uploaded, failed: failed || undefined };
|
|
}
|
|
|
|
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
|
const dl = await downloadFeed(db);
|
|
if (dl.error) return dl;
|
|
|
|
const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
|
|
let uploaded = 0;
|
|
if (uploadEnabled) {
|
|
const ul = await uploadFeed(db);
|
|
uploaded = ul.uploaded ?? 0;
|
|
}
|
|
|
|
return { ...dl, uploaded: uploaded || undefined };
|
|
}
|
|
|
|
export async function countPendingUploads(db: SQLiteDatabase): Promise<number> {
|
|
const row = db.getFirstSync<{ n: number }>(
|
|
`SELECT COUNT(*) as n FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
|
);
|
|
return row?.n ?? 0;
|
|
}
|
|
|
|
async function uploadLocalActivities(
|
|
db: SQLiteDatabase,
|
|
instanceUrl: string,
|
|
token: string,
|
|
onProgress?: (n: number, total: number) => void,
|
|
): Promise<{ uploaded: number; failed: number }> {
|
|
const rows = db.getAllSync<{
|
|
id: string;
|
|
detail_json: string;
|
|
timeseries_json: string | null;
|
|
geojson: string | null;
|
|
original_path: string | null;
|
|
}>(
|
|
`SELECT id, detail_json, timeseries_json, geojson, original_path
|
|
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
|
);
|
|
|
|
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
|
let uploaded = 0;
|
|
let failed = 0;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const total = rows.length;
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const row = rows[i];
|
|
onProgress?.(i + 1, total);
|
|
try {
|
|
let resp: Response;
|
|
|
|
// Prefer raw upload when the original FIT/GPX/TCX file is still on disk.
|
|
// The server re-extracts it with DEM elevation correction, producing better data.
|
|
const useRaw = row.original_path !== null &&
|
|
(await FileSystem.getInfoAsync(row.original_path)).exists;
|
|
|
|
if (useRaw) {
|
|
const filename = row.original_path!.split('/').pop() ?? 'activity.fit';
|
|
const base64 = await FileSystem.readAsStringAsync(row.original_path!, {
|
|
encoding: FileSystem.EncodingType.Base64,
|
|
});
|
|
resp = await fetch(`${instanceUrl}/api/upload/raw`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({ filename, base64 }),
|
|
});
|
|
} else {
|
|
const detail = JSON.parse(row.detail_json);
|
|
const body: Record<string, unknown> = { activity: { id: row.id, ...detail } };
|
|
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
|
|
if (row.geojson) body.geojson = JSON.parse(row.geojson);
|
|
resp = await fetch(`${instanceUrl}/api/upload/bas`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
if (resp.ok) {
|
|
await db.runAsync(`UPDATE activities SET synced_at = ? WHERE id = ?`, [now, row.id]);
|
|
uploaded++;
|
|
} else {
|
|
console.warn(`upload ${row.id}: HTTP ${resp.status}`);
|
|
failed++;
|
|
}
|
|
} catch (err) {
|
|
console.warn(`upload ${row.id}:`, err);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
return { uploaded, failed };
|
|
}
|
|
|
|
type RemoteSummary = {
|
|
id: string;
|
|
title?: string;
|
|
sport?: string;
|
|
started_at?: string;
|
|
distance_m?: number | null;
|
|
moving_time_s?: number | null;
|
|
elevation_gain_m?: number | null;
|
|
avg_speed_kmh?: number | null;
|
|
avg_hr_bpm?: number | null;
|
|
avg_power_w?: number | null;
|
|
};
|