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:
@@ -487,6 +487,58 @@ async def get_activity_timeseries(
|
|||||||
raise HTTPException(404, "Timeseries not found")
|
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")
|
@app.get("/api/wheel/version")
|
||||||
async def wheel_version() -> JSONResponse:
|
async def wheel_version() -> JSONResponse:
|
||||||
"""Public endpoint: current bincio wheel version for mobile app update checks."""
|
"""Public endpoint: current bincio wheel version for mobile app update checks."""
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ export default function FeedScreen() {
|
|||||||
setSyncMsg(result.error);
|
setSyncMsg(result.error);
|
||||||
} else if (result.total === 0) {
|
} else if (result.total === 0) {
|
||||||
setSyncMsg('No activities on instance');
|
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)`);
|
setSyncMsg(`Up to date (${result.total} activities)`);
|
||||||
} else {
|
} else {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (result.synced > 0) parts.push(`${result.synced} new`);
|
if (result.synced > 0) parts.push(`${result.synced} new`);
|
||||||
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
|
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)`);
|
setSyncMsg(`Synced: ${parts.join(', ')} (${result.total} total)`);
|
||||||
}
|
}
|
||||||
setTimeout(() => setSyncMsg(null), 3500);
|
setTimeout(() => setSyncMsg(null), 3500);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default function SettingsScreen() {
|
|||||||
const storedPath = useSetting('auto_import_path') ?? '';
|
const storedPath = useSetting('auto_import_path') ?? '';
|
||||||
const storedToken = useSetting('api_token');
|
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 [instanceUrl, setInstanceUrl] = useState(storedUrl);
|
||||||
const [handle, setHandle] = useState(storedHandle);
|
const [handle, setHandle] = useState(storedHandle);
|
||||||
@@ -176,6 +177,7 @@ export default function SettingsScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Section title="Sync">
|
<Section title="Sync">
|
||||||
|
<Text style={styles.subLabel}>Download</Text>
|
||||||
<View style={styles.modeRow}>
|
<View style={styles.modeRow}>
|
||||||
<ModeButton
|
<ModeButton
|
||||||
label="Summaries only"
|
label="Summaries only"
|
||||||
@@ -193,6 +195,24 @@ export default function SettingsScreen() {
|
|||||||
? 'Downloads map route and elevation chart for every activity during sync. Uses more storage and takes longer.'
|
? 'Downloads map route and elevation chart for every activity during sync. Uses more storage and takes longer.'
|
||||||
: 'Syncs activity summaries only. Map and chart are fetched on demand when you open an activity.'}
|
: 'Syncs activity summaries only. Map and chart are fetched on demand when you open an activity.'}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text style={[styles.subLabel, { borderTopWidth: 1, borderTopColor: '#27272a', paddingTop: 12 }]}>Upload</Text>
|
||||||
|
<View style={styles.modeRow}>
|
||||||
|
<ModeButton
|
||||||
|
label="Off"
|
||||||
|
active={!storedSyncUpload}
|
||||||
|
onPress={() => setSetting(db, 'sync_upload', 'false')}
|
||||||
|
/>
|
||||||
|
<ModeButton
|
||||||
|
label="Upload local activities"
|
||||||
|
active={storedSyncUpload}
|
||||||
|
onPress={() => setSetting(db, 'sync_upload', 'true')}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
{storedSyncUpload
|
||||||
|
? 'Local activities are uploaded to the instance during sync.'
|
||||||
|
: 'Local activities stay on device only.'}
|
||||||
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Data">
|
<Section title="Data">
|
||||||
@@ -314,6 +334,7 @@ const styles = StyleSheet.create({
|
|||||||
disconnectText: { color: '#71717a', fontSize: 14 },
|
disconnectText: { color: '#71717a', fontSize: 14 },
|
||||||
msgOk: { color: '#86efac', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
|
msgOk: { color: '#86efac', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
|
||||||
msgErr: { color: '#fca5a5', 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 },
|
modeRow: { flexDirection: 'row', gap: 8, padding: 12 },
|
||||||
modeButton: { flex: 1, paddingVertical: 9, borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46', alignItems: 'center' },
|
modeButton: { flex: 1, paddingVertical: 9, borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46', alignItems: 'center' },
|
||||||
modeButtonActive: { backgroundColor: '#1e3a5f', borderColor: '#2563eb' },
|
modeButtonActive: { backgroundColor: '#1e3a5f', borderColor: '#2563eb' },
|
||||||
|
|||||||
+67
-3
@@ -1,7 +1,14 @@
|
|||||||
|
import * as FileSystem from 'expo-file-system/legacy';
|
||||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
import type { SQLiteDatabase } from 'expo-sqlite';
|
||||||
import { getSetting, upsertRemoteActivity } from './queries';
|
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> {
|
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
||||||
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
|
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
|
||||||
@@ -50,8 +57,15 @@ export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
|
|||||||
if (changed) synced++;
|
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') {
|
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
|
// 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 = {
|
type RemoteSummary = {
|
||||||
|
|||||||
Reference in New Issue
Block a user