From c7c7fe9395a147d4d3485753b0be5b54b5dc9547 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Fri, 24 Apr 2026 22:26:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20bidirectional=20sync=20=E2=80=94=20uplo?= =?UTF-8?q?ad=20local=20activities=20to=20remote=20instance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bincio/serve/server.py | 52 +++++++++++++++++++++++++ mobile/app/(tabs)/index.tsx | 3 +- mobile/app/(tabs)/settings.tsx | 23 ++++++++++- mobile/db/sync.ts | 70 ++++++++++++++++++++++++++++++++-- 4 files changed, 143 insertions(+), 5 deletions(-) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index c6a93f2..b1da0f1 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -487,6 +487,58 @@ async def get_activity_timeseries( raise HTTPException(404, "Timeseries not found") +@app.post("/api/upload/bas") +async def upload_bas_activity( + request: Request, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Accept a pre-extracted BAS activity JSON from the mobile app. + + Body (JSON): + activity – full BAS activity dict (required, must have 'id') + timeseries – timeseries dict (optional) + geojson – GeoJSON dict (optional) + + Returns: + {"ok": true, "id": "...", "status": "imported" | "duplicate"} + """ + user = _require_auth(request, bincio_session) + body = await request.json() + + activity = body.get("activity") + if not activity or not activity.get("id"): + raise HTTPException(400, "Missing activity.id") + + activity_id = str(activity["id"]) + _check_id(activity_id) + + user_dir = _get_data_dir() / user.handle + acts_dir = user_dir / "activities" + acts_dir.mkdir(parents=True, exist_ok=True) + + out = acts_dir / f"{activity_id}.json" + if out.exists(): + return JSONResponse({"ok": True, "id": activity_id, "status": "duplicate"}) + + out.write_text(json.dumps(activity, ensure_ascii=False, indent=2), encoding="utf-8") + + if body.get("timeseries"): + ts_path = acts_dir / f"{activity_id}.timeseries.json" + if not ts_path.exists(): + ts_path.write_text(json.dumps(body["timeseries"], ensure_ascii=False), encoding="utf-8") + + if body.get("geojson"): + gj_path = acts_dir / f"{activity_id}.geojson" + if not gj_path.exists(): + gj_path.write_text(json.dumps(body["geojson"], ensure_ascii=False), encoding="utf-8") + + from bincio.render.merge import merge_all + merge_all(user_dir) + + log.info("upload/bas[%s]: imported %s", user.handle, activity_id) + return JSONResponse({"ok": True, "id": activity_id, "status": "imported"}) + + @app.get("/api/wheel/version") async def wheel_version() -> JSONResponse: """Public endpoint: current bincio wheel version for mobile app update checks.""" diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index 144cb1b..d0f369b 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -20,12 +20,13 @@ export default function FeedScreen() { setSyncMsg(result.error); } else if (result.total === 0) { setSyncMsg('No activities on instance'); - } else if (result.synced === 0 && !result.fetched) { + } else if (result.synced === 0 && !result.fetched && !result.uploaded) { setSyncMsg(`Up to date (${result.total} activities)`); } else { const parts = []; if (result.synced > 0) parts.push(`${result.synced} new`); if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`); + if (result.uploaded) parts.push(`${result.uploaded} uploaded`); setSyncMsg(`Synced: ${parts.join(', ')} (${result.total} total)`); } setTimeout(() => setSyncMsg(null), 3500); diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx index b564075..f986250 100644 --- a/mobile/app/(tabs)/settings.tsx +++ b/mobile/app/(tabs)/settings.tsx @@ -13,7 +13,8 @@ export default function SettingsScreen() { const storedHandle = useSetting('handle') ?? ''; const storedPath = useSetting('auto_import_path') ?? ''; const storedToken = useSetting('api_token'); - const storedSyncMode = (useSetting('sync_mode') ?? 'summaries') as 'summaries' | 'full'; + const storedSyncMode = (useSetting('sync_mode') ?? 'summaries') as 'summaries' | 'full'; + const storedSyncUpload = useSetting('sync_upload') === 'true'; const [instanceUrl, setInstanceUrl] = useState(storedUrl); const [handle, setHandle] = useState(storedHandle); @@ -176,6 +177,7 @@ export default function SettingsScreen() { )}
+ Download + Upload + + setSetting(db, 'sync_upload', 'false')} + /> + setSetting(db, 'sync_upload', 'true')} + /> + + + {storedSyncUpload + ? 'Local activities are uploaded to the instance during sync.' + : 'Local activities stay on device only.'} +
@@ -314,6 +334,7 @@ const styles = StyleSheet.create({ disconnectText: { color: '#71717a', fontSize: 14 }, msgOk: { color: '#86efac', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 }, msgErr: { color: '#fca5a5', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 }, + subLabel: { color: '#52525b', fontSize: 11, fontWeight: '600', letterSpacing: 0.6, paddingHorizontal: 12, paddingTop: 12, paddingBottom: 4 }, modeRow: { flexDirection: 'row', gap: 8, padding: 12 }, modeButton: { flex: 1, paddingVertical: 9, borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46', alignItems: 'center' }, modeButtonActive: { backgroundColor: '#1e3a5f', borderColor: '#2563eb' }, diff --git a/mobile/db/sync.ts b/mobile/db/sync.ts index 2e46e3a..79bfccc 100644 --- a/mobile/db/sync.ts +++ b/mobile/db/sync.ts @@ -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 { const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, ''); @@ -50,8 +57,15 @@ export async function syncFeed(db: SQLiteDatabase): Promise { 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 { } } - 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 { + 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 = { 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 = {