2f53fbc359
- Import tab now accepts multiple files at once (DocumentPicker multiple:true), processes them sequentially through Pyodide, and shows a summary with per-file errors on completion. - DB migration v2 adds source_path column (original filesystem path before copy) and an index on it, enabling O(1) deduplication for watch-folder imports. - On Android, if auto_import_path is set, the Import tab scans the directory on mount and on AppState 'active' (app foreground), then automatically imports any FIT files not yet in the DB. Designed for Karoo: finish a ride, open the app, new files import without any manual steps. - insertActivity now accepts optional source_path; both importBasJson and importNativeFile pass it through (null for files picked via DocumentPicker, real path for watch-folder files).
186 lines
5.8 KiB
TypeScript
186 lines
5.8 KiB
TypeScript
import { useSQLiteContext } from 'expo-sqlite';
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
export type ActivityRow = {
|
|
id: string;
|
|
source_hash: string;
|
|
detail_json: string;
|
|
timeseries_json: string | null;
|
|
geojson: string | null;
|
|
original_path: string | null;
|
|
source_path: string | null;
|
|
synced_at: number | null;
|
|
origin: 'local' | 'remote';
|
|
created_at: number;
|
|
};
|
|
|
|
export type ActivitySummary = {
|
|
id: string;
|
|
title: string;
|
|
sport: string;
|
|
started_at: string;
|
|
distance_m: number | null;
|
|
duration_s: number | null;
|
|
elevation_gain_m: number | null;
|
|
origin: 'local' | 'remote';
|
|
synced_at: number | null;
|
|
};
|
|
|
|
// ── Activities ─────────────────────────────────────────────────────────────
|
|
|
|
export function useActivities(): ActivitySummary[] {
|
|
const db = useSQLiteContext();
|
|
// Summaries are derived from the stored detail_json at query time.
|
|
// JSON extraction via SQLite's json_extract keeps the table schema simple.
|
|
const rows = db.getAllSync<{
|
|
id: string;
|
|
origin: 'local' | 'remote';
|
|
synced_at: number | null;
|
|
title: string;
|
|
sport: string;
|
|
started_at: string;
|
|
distance_m: number | null;
|
|
duration_s: number | null;
|
|
elevation_gain_m: number | null;
|
|
}>(`
|
|
SELECT
|
|
id, origin, synced_at,
|
|
json_extract(detail_json, '$.title') AS title,
|
|
json_extract(detail_json, '$.sport') AS sport,
|
|
json_extract(detail_json, '$.started_at') AS started_at,
|
|
json_extract(detail_json, '$.distance_m') AS distance_m,
|
|
json_extract(detail_json, '$.duration_s') AS duration_s,
|
|
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
|
|
FROM activities
|
|
ORDER BY json_extract(detail_json, '$.started_at') DESC
|
|
`);
|
|
return rows;
|
|
}
|
|
|
|
export function useActivity(id: string): ActivityRow | null {
|
|
const db = useSQLiteContext();
|
|
return db.getFirstSync<ActivityRow>(
|
|
'SELECT * FROM activities WHERE id = ?',
|
|
[id],
|
|
) ?? null;
|
|
}
|
|
|
|
export async function insertActivity(
|
|
db: ReturnType<typeof useSQLiteContext>,
|
|
row: Pick<ActivityRow, 'id' | 'source_hash' | 'detail_json' | 'timeseries_json' | 'geojson' | 'original_path' | 'origin'>
|
|
& { source_path?: string | null },
|
|
): Promise<void> {
|
|
await db.runAsync(
|
|
`INSERT OR IGNORE INTO activities
|
|
(id, source_hash, detail_json, timeseries_json, geojson, original_path, source_path, origin)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
row.id,
|
|
row.source_hash,
|
|
row.detail_json,
|
|
row.timeseries_json ?? null,
|
|
row.geojson ?? null,
|
|
row.original_path ?? null,
|
|
row.source_path ?? null,
|
|
row.origin,
|
|
],
|
|
);
|
|
}
|
|
|
|
export function isSourcePathImported(
|
|
db: ReturnType<typeof useSQLiteContext>,
|
|
sourcePath: string,
|
|
): boolean {
|
|
const row = db.getFirstSync<{ id: string }>(
|
|
'SELECT id FROM activities WHERE source_path = ?',
|
|
[sourcePath],
|
|
);
|
|
return row != null;
|
|
}
|
|
|
|
export async function upsertRemoteActivity(
|
|
db: ReturnType<typeof useSQLiteContext>,
|
|
id: string,
|
|
detailJson: string,
|
|
): Promise<boolean> {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const result = await db.runAsync(
|
|
`INSERT INTO activities (id, source_hash, detail_json, origin, synced_at)
|
|
VALUES (?, ?, ?, 'remote', ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
detail_json = excluded.detail_json,
|
|
synced_at = excluded.synced_at
|
|
WHERE origin = 'remote'`,
|
|
[id, id, detailJson, now],
|
|
);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
export async function deleteRemoteActivities(
|
|
db: ReturnType<typeof useSQLiteContext>,
|
|
): Promise<number> {
|
|
const result = await db.runAsync(`DELETE FROM activities WHERE origin = 'remote'`);
|
|
return result.changes;
|
|
}
|
|
|
|
export async function deleteActivity(
|
|
db: ReturnType<typeof useSQLiteContext>,
|
|
id: string,
|
|
): Promise<string | null> {
|
|
const row = db.getFirstSync<{ original_path: string | null }>(
|
|
'SELECT original_path FROM activities WHERE id = ?',
|
|
[id],
|
|
);
|
|
await db.runAsync('DELETE FROM activities WHERE id = ?', [id]);
|
|
return row?.original_path ?? null;
|
|
}
|
|
|
|
export async function deleteActivities(
|
|
db: ReturnType<typeof useSQLiteContext>,
|
|
ids: string[],
|
|
): Promise<Array<string | null>> {
|
|
if (ids.length === 0) return [];
|
|
const rows = db.getAllSync<{ original_path: string | null }>(
|
|
`SELECT original_path FROM activities WHERE id IN (${ids.map(() => '?').join(',')})`,
|
|
ids,
|
|
);
|
|
const placeholders = ids.map(() => '?').join(',');
|
|
await db.runAsync(`DELETE FROM activities WHERE id IN (${placeholders})`, ids);
|
|
return rows.map(r => r.original_path ?? null);
|
|
}
|
|
|
|
// ── Settings ───────────────────────────────────────────────────────────────
|
|
|
|
export async function getSetting(
|
|
db: ReturnType<typeof useSQLiteContext>,
|
|
key: string,
|
|
): Promise<string | null> {
|
|
const row = db.getFirstSync<{ value: string }>(
|
|
'SELECT value FROM settings WHERE key = ?',
|
|
[key],
|
|
);
|
|
return row?.value ?? null;
|
|
}
|
|
|
|
export async function setSetting(
|
|
db: ReturnType<typeof useSQLiteContext>,
|
|
key: string,
|
|
value: string,
|
|
): Promise<void> {
|
|
await db.runAsync(
|
|
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
[key, value],
|
|
);
|
|
}
|
|
|
|
export function useSetting(key: string): string | null {
|
|
const db = useSQLiteContext();
|
|
const row = db.getFirstSync<{ value: string }>(
|
|
'SELECT value FROM settings WHERE key = ?',
|
|
[key],
|
|
);
|
|
return row?.value ?? null;
|
|
}
|