feat: bidirectional sync — upload local activities to remote instance
- Server: POST /api/upload/bas accepts pre-extracted BAS JSON (activity + optional timeseries/geojson), writes files and triggers merge_all - sync.ts: uploadLocalActivities reads unsynced local activities by original_path, POSTs to /api/upload/bas, marks synced_at on success - Settings: Upload toggle (Off / Upload local activities) in Sync section with subLabel dividers for Download / Upload groups - Feed: sync message includes uploaded count when activities are pushed
This commit is contained in:
+67
-3
@@ -1,7 +1,14 @@
|
||||
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; error?: string };
|
||||
export type SyncResult = {
|
||||
synced: number;
|
||||
total: number;
|
||||
fetched?: number;
|
||||
uploaded?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
|
||||
@@ -50,8 +57,15 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
if (changed) synced++;
|
||||
}
|
||||
|
||||
// Upload local activities to the server if enabled
|
||||
const uploadEnabled = (await getSetting(db, 'sync_upload')) === 'true';
|
||||
let uploaded = 0;
|
||||
if (uploadEnabled) {
|
||||
uploaded = await uploadLocalActivities(db, instanceUrl, token);
|
||||
}
|
||||
|
||||
if (syncMode !== 'full') {
|
||||
return { synced, total: activities.length };
|
||||
return { synced, total: activities.length, uploaded: uploaded || undefined };
|
||||
}
|
||||
|
||||
// Full mode: fetch geojson + timeseries for any activity missing them
|
||||
@@ -89,7 +103,57 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||
}
|
||||
}
|
||||
|
||||
return { synced, total: activities.length, fetched };
|
||||
return { synced, total: activities.length, fetched, uploaded: uploaded || undefined };
|
||||
}
|
||||
|
||||
async function uploadLocalActivities(
|
||||
db: SQLiteDatabase,
|
||||
instanceUrl: string,
|
||||
token: string,
|
||||
): Promise<number> {
|
||||
const rows = db.getAllSync<{ id: string; original_path: string | null; timeseries_json: string | null; geojson: string | null }>(
|
||||
`SELECT id, original_path, timeseries_json, geojson
|
||||
FROM activities WHERE origin = 'local' AND synced_at IS NULL`,
|
||||
);
|
||||
|
||||
const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||||
let uploaded = 0;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
let activity: object | null = null;
|
||||
|
||||
if (row.original_path) {
|
||||
const text = await FileSystem.readAsStringAsync(row.original_path);
|
||||
activity = JSON.parse(text);
|
||||
}
|
||||
|
||||
if (!activity) continue;
|
||||
|
||||
const body: Record<string, unknown> = { activity };
|
||||
if (row.timeseries_json) body.timeseries = JSON.parse(row.timeseries_json);
|
||||
if (row.geojson) body.geojson = JSON.parse(row.geojson);
|
||||
|
||||
const 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++;
|
||||
}
|
||||
} catch {
|
||||
// skip failed activities, continue with others
|
||||
}
|
||||
}
|
||||
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
type RemoteSummary = {
|
||||
|
||||
Reference in New Issue
Block a user