use crate::scope; use geo::algorithm::Distance; use geo::{ClosestPoint, Closest, Coord, Geodesic, LineString, Point}; use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; use std::path::Path; use tauri::State; pub struct AppState { pub pool: SqlitePool, } #[derive(Debug, Deserialize)] struct FixedAssetInput { row_id: String, asset_name: String, video_name: String, coord: CoordValue, image_path: Option, #[serde(default)] deleted: bool, #[serde(default)] side: Option, } #[derive(Debug, Deserialize)] #[serde(untagged)] enum CoordValue { Floats([f64; 2]), Strings([String; 2]), } impl CoordValue { fn to_lat_lng(&self) -> Result<(f64, f64), String> { match self { CoordValue::Floats([lat, lng]) => Ok((*lat, *lng)), CoordValue::Strings([lat, lng]) => { let lat: f64 = lat.parse().map_err(|_| "invalid lat string".to_string())?; let lng: f64 = lng.parse().map_err(|_| "invalid lng string".to_string())?; Ok((lat, lng)) } } } } #[derive(Debug, Deserialize)] #[serde(untagged)] enum NumOrStr { Num(f64), Str(String), } impl NumOrStr { fn to_f64(&self) -> Result { match self { NumOrStr::Num(v) => Ok(*v), NumOrStr::Str(s) => s .parse::() .map_err(|_| format!("invalid float: {}", s)), } } } #[derive(Debug, Deserialize)] struct RangeAssetInput { row_id: String, asset_name: String, video_name: String, // Midpoint is optional: real data sometimes ships only start+end (no // mid-frame annotated). When absent we synthesize (start+end)/2 below so // the line still renders; the popup gates the M pin on image_path2 so a // synthesized midpoint never shows as a third pin. #[serde(default)] coord_lat: Option, #[serde(default)] coord_lng: Option, start_coord_lat: NumOrStr, start_coord_lng: NumOrStr, end_coord_lat: NumOrStr, end_coord_lng: NumOrStr, image_path1: Option, image_path2: Option, image_path3: Option, #[serde(default)] deleted: bool, #[serde(default)] side: Option, } fn opt_num_or_str_to_f64(v: &Option) -> Option { match v.as_ref()? { NumOrStr::Num(n) => Some(*n).filter(|x| x.is_finite()), NumOrStr::Str(s) => { let t = s.trim(); if t.is_empty() { return None; } t.parse::().ok().filter(|x| x.is_finite()) } } } use std::sync::{OnceLock, RwLock}; static CURRENT_USER: OnceLock> = OnceLock::new(); fn user_id() -> String { CURRENT_USER .get_or_init(|| RwLock::new("default".to_string())) .read() .unwrap() .clone() } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct User { pub id: i64, pub username: String, pub display_name: String, pub created_at: String, } #[tauri::command] pub async fn list_users(state: State<'_, AppState>) -> Result, String> { sqlx::query_as::<_, User>( "SELECT id, username, display_name, created_at FROM users ORDER BY username", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn create_user( username: String, display_name: String, state: State<'_, AppState>, ) -> Result { let row: (i64,) = sqlx::query_as( "INSERT INTO users (username, display_name) VALUES (?, ?) RETURNING id", ) .bind(username.trim()) .bind(display_name.trim()) .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; Ok(row.0) } #[tauri::command] pub async fn delete_user(id: i64, state: State<'_, AppState>) -> Result<(), String> { sqlx::query("DELETE FROM users WHERE id = ?") .bind(id) .execute(&state.pool) .await .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub fn set_current_user(username: String) -> Result<(), String> { let lock = CURRENT_USER.get_or_init(|| RwLock::new("default".to_string())); *lock.write().unwrap() = username; Ok(()) } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct AuditRow { pub id: i64, pub action_type: String, pub scope: String, pub asset_ids: String, pub user_id: String, pub before: Option, pub after: Option, pub ts: String, pub undone: i64, } #[derive(Debug, Deserialize, Serialize)] struct MoveAfter { vertex: String, lat: f64, lng: f64, } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Asset { pub id: i64, pub row_id: String, pub asset_type: String, pub asset_name: String, pub video_name: String, pub lat: Option, pub lng: Option, pub start_lat: Option, pub start_lng: Option, pub end_lat: Option, pub end_lng: Option, pub image_path: Option, pub image_path1: Option, pub image_path2: Option, pub image_path3: Option, pub deleted: i64, pub modified: i64, pub merged_into: Option, pub in_scope: Option, pub side: Option, pub link_pair_id: Option, pub link_locked: Option, } fn normalize_side(s: &str) -> Option { match s.trim().to_uppercase().as_str() { "LHS" | "LEFT" | "L" => Some("Left".to_string()), "RHS" | "RIGHT" | "R" => Some("Right".to_string()), _ => None, } } // Per-row insert helpers extracted so the auto-detect importer // (`import_assets_auto`) can share the exact INSERT/ON CONFLICT logic with // the type-specific importers — keeps re-import preserve-edits semantics // identical no matter which entry path the user takes. async fn insert_fixed_row( tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, input: &FixedAssetInput, ) -> Result<(), String> { let (lat, lng) = input.coord.to_lat_lng()?; let side = input.side.as_deref().and_then(normalize_side); sqlx::query( r#" INSERT INTO assets ( row_id, asset_type, asset_name, video_name, lat, lng, min_lat, max_lat, min_lng, max_lng, image_path, deleted, side ) VALUES (?, 'fixed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(row_id) DO UPDATE SET asset_type = excluded.asset_type, asset_name = excluded.asset_name, video_name = excluded.video_name, lat = CASE WHEN assets.modified = 0 THEN excluded.lat ELSE assets.lat END, lng = CASE WHEN assets.modified = 0 THEN excluded.lng ELSE assets.lng END, min_lat = CASE WHEN assets.modified = 0 THEN excluded.min_lat ELSE assets.min_lat END, max_lat = CASE WHEN assets.modified = 0 THEN excluded.max_lat ELSE assets.max_lat END, min_lng = CASE WHEN assets.modified = 0 THEN excluded.min_lng ELSE assets.min_lng END, max_lng = CASE WHEN assets.modified = 0 THEN excluded.max_lng ELSE assets.max_lng END, image_path = excluded.image_path, -- Clear range-only fields when a row migrates range → fixed. -- Fixed assets carry only `image_path` and a single (lat, lng); -- leaving the old triple paths or start/end coords in place -- would render stale S/M/E pins and a stale popup image. image_path1 = NULL, image_path2 = NULL, image_path3 = NULL, start_lat = NULL, start_lng = NULL, end_lat = NULL, end_lng = NULL, deleted = CASE WHEN assets.deleted = 1 THEN 1 ELSE excluded.deleted END, side = CASE WHEN assets.modified = 0 THEN excluded.side ELSE assets.side END "#, ) .bind(&input.row_id) .bind(&input.asset_name) .bind(&input.video_name) .bind(lat) .bind(lng) .bind(lat) .bind(lat) .bind(lng) .bind(lng) .bind(&input.image_path) .bind(input.deleted as i64) .bind(&side) .execute(&mut **tx) .await .map_err(|e| e.to_string())?; Ok(()) } async fn insert_range_row( tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, input: &RangeAssetInput, ) -> Result<(), String> { let s_lat = input.start_coord_lat.to_f64()?; let s_lng = input.start_coord_lng.to_f64()?; let e_lat = input.end_coord_lat.to_f64()?; let e_lng = input.end_coord_lng.to_f64()?; // Synthesize midpoint when missing/empty so the line still renders. // Whether the midpoint is real is communicated to the UI via image_path2: // popup gates the M pin on that, so a synthesized mid never shows as a // third pin. let mid_lat = opt_num_or_str_to_f64(&input.coord_lat).unwrap_or((s_lat + e_lat) / 2.0); let mid_lng = opt_num_or_str_to_f64(&input.coord_lng).unwrap_or((s_lng + e_lng) / 2.0); let min_lat = mid_lat.min(s_lat).min(e_lat); let max_lat = mid_lat.max(s_lat).max(e_lat); let min_lng = mid_lng.min(s_lng).min(e_lng); let max_lng = mid_lng.max(s_lng).max(e_lng); let side = input.side.as_deref().and_then(normalize_side); sqlx::query( r#" INSERT INTO assets ( row_id, asset_type, asset_name, video_name, lat, lng, start_lat, start_lng, end_lat, end_lng, min_lat, max_lat, min_lng, max_lng, image_path1, image_path2, image_path3, deleted, side ) VALUES (?, 'range', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(row_id) DO UPDATE SET asset_type = excluded.asset_type, asset_name = excluded.asset_name, video_name = excluded.video_name, lat = CASE WHEN assets.modified = 0 THEN excluded.lat ELSE assets.lat END, lng = CASE WHEN assets.modified = 0 THEN excluded.lng ELSE assets.lng END, start_lat = CASE WHEN assets.modified = 0 THEN excluded.start_lat ELSE assets.start_lat END, start_lng = CASE WHEN assets.modified = 0 THEN excluded.start_lng ELSE assets.start_lng END, end_lat = CASE WHEN assets.modified = 0 THEN excluded.end_lat ELSE assets.end_lat END, end_lng = CASE WHEN assets.modified = 0 THEN excluded.end_lng ELSE assets.end_lng END, min_lat = CASE WHEN assets.modified = 0 THEN excluded.min_lat ELSE assets.min_lat END, max_lat = CASE WHEN assets.modified = 0 THEN excluded.max_lat ELSE assets.max_lat END, min_lng = CASE WHEN assets.modified = 0 THEN excluded.min_lng ELSE assets.min_lng END, max_lng = CASE WHEN assets.modified = 0 THEN excluded.max_lng ELSE assets.max_lng END, image_path1 = excluded.image_path1, image_path2 = excluded.image_path2, image_path3 = excluded.image_path3, -- Clear the singular `image_path` when a row migrates fixed → -- range. Range assets use image_path1/2/3 only; leaving the old -- singular path would surface as a stale "main" image in any -- consumer that prefers it over the triple. image_path = NULL, deleted = CASE WHEN assets.deleted = 1 THEN 1 ELSE excluded.deleted END, side = CASE WHEN assets.modified = 0 THEN excluded.side ELSE assets.side END "#, ) .bind(&input.row_id) .bind(&input.asset_name) .bind(&input.video_name) .bind(mid_lat) .bind(mid_lng) .bind(s_lat) .bind(s_lng) .bind(e_lat) .bind(e_lng) .bind(min_lat) .bind(max_lat) .bind(min_lng) .bind(max_lng) .bind(&input.image_path1) .bind(&input.image_path2) .bind(&input.image_path3) .bind(input.deleted as i64) .bind(&side) .execute(&mut **tx) .await .map_err(|e| e.to_string())?; Ok(()) } /// Parse a single bbox prefix from a row and write it into asset_bboxes for /// the given slot. Accepts numeric or string values and silently skips slots /// where any of the four coordinate fields are missing / unparseable. Class /// is asset-level (assets.bbox_class) and handled separately by the caller. async fn maybe_insert_bbox_from_row( tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, asset_id: i64, row: &serde_json::Value, prefix: &str, slot: &str, ) -> Result<(), String> { fn parse_f(v: Option<&serde_json::Value>) -> Option { match v? { serde_json::Value::Number(n) => n.as_f64().filter(|x| x.is_finite()), serde_json::Value::String(s) => { let t = s.trim(); if t.is_empty() { None } else { t.parse::().ok().filter(|x| x.is_finite()) } } _ => None, } } let x1 = parse_f(row.get(&format!("{prefix}bbox_x1"))); let y1 = parse_f(row.get(&format!("{prefix}bbox_y1"))); let x2 = parse_f(row.get(&format!("{prefix}bbox_x2"))); let y2 = parse_f(row.get(&format!("{prefix}bbox_y2"))); let (Some(x1), Some(y1), Some(x2), Some(y2)) = (x1, y1, x2, y2) else { return Ok(()); }; let (nx1, nx2) = if x1 <= x2 { (x1, x2) } else { (x2, x1) }; let (ny1, ny2) = if y1 <= y2 { (y1, y2) } else { (y2, y1) }; if (nx2 - nx1).abs() < 1.0 || (ny2 - ny1).abs() < 1.0 { return Ok(()); } sqlx::query( "INSERT INTO asset_bboxes(asset_id, slot, x1, y1, x2, y2) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(asset_id, slot) DO UPDATE SET x1 = excluded.x1, y1 = excluded.y1, x2 = excluded.x2, y2 = excluded.y2", ) .bind(asset_id) .bind(slot) .bind(nx1) .bind(ny1) .bind(nx2) .bind(ny2) .execute(&mut **tx) .await .map_err(|e| e.to_string())?; Ok(()) } /// Resolve the asset id for a row_id we just inserted, then attempt to ingest /// any bbox fields. For fixed rows we look for the flat `bbox_*` keys; for /// range rows we additionally check `start_bbox_*` / `mid_bbox_*` / /// `end_bbox_*` so each image-slot can carry its own bbox. The (asset-level) /// `bbox_class` field, if present anywhere in the row, is stored on assets. async fn ingest_bboxes_for_row( tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, row_id: &str, raw: &serde_json::Value, is_range: bool, ) -> Result<(), String> { let asset_id: Option = sqlx::query_scalar("SELECT id FROM assets WHERE row_id = ?") .bind(row_id) .fetch_optional(&mut **tx) .await .map_err(|e| e.to_string())?; let Some(asset_id) = asset_id else { return Ok(()); }; if is_range { // Flat bbox_* maps to mid by convention; explicit per-slot prefixes // override that when present. maybe_insert_bbox_from_row(tx, asset_id, raw, "", "mid").await?; maybe_insert_bbox_from_row(tx, asset_id, raw, "start_", "start").await?; maybe_insert_bbox_from_row(tx, asset_id, raw, "mid_", "mid").await?; maybe_insert_bbox_from_row(tx, asset_id, raw, "end_", "end").await?; } else { maybe_insert_bbox_from_row(tx, asset_id, raw, "", "fixed").await?; } // Class lives on assets.asset_name, populated by the regular import path. // No bbox_class write here — `set_asset_bbox` API stays purely geometric // and class renames flow through the existing rename_assets command. let _ = asset_id; Ok(()) } async fn stamp_orig_positions(pool: &SqlitePool) -> Result<(), String> { sqlx::query( "UPDATE assets SET orig_lat = COALESCE(orig_lat, lat), orig_lng = COALESCE(orig_lng, lng), orig_start_lat = COALESCE(orig_start_lat, start_lat), orig_start_lng = COALESCE(orig_start_lng, start_lng), orig_end_lat = COALESCE(orig_end_lat, end_lat), orig_end_lng = COALESCE(orig_end_lng, end_lng)", ) .execute(pool) .await .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn import_fixed_assets( path: String, state: State<'_, AppState>, ) -> Result { let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?; // Deserialize as raw JSON first so we can still see bbox_* keys that // aren't on FixedAssetInput. Each row is then converted to the typed // shape for INSERT and re-used for bbox ingest. let raws: Vec = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut count = 0usize; for raw in raws { let input: FixedAssetInput = serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?; let row_id = input.row_id.clone(); insert_fixed_row(&mut tx, &input).await?; ingest_bboxes_for_row(&mut tx, &row_id, &raw, false).await?; count += 1; } tx.commit().await.map_err(|e| e.to_string())?; stamp_orig_positions(&state.pool).await?; apply_scope_after_change(&state.pool).await?; Ok(count) } #[tauri::command] pub async fn import_range_assets( path: String, state: State<'_, AppState>, ) -> Result { let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?; let raws: Vec = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut count = 0usize; for raw in raws { let input: RangeAssetInput = serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?; let row_id = input.row_id.clone(); insert_range_row(&mut tx, &input).await?; ingest_bboxes_for_row(&mut tx, &row_id, &raw, true).await?; count += 1; } tx.commit().await.map_err(|e| e.to_string())?; stamp_orig_positions(&state.pool).await?; apply_scope_after_change(&state.pool).await?; Ok(count) } #[derive(Debug, Serialize)] pub struct AutoImportResult { pub fixed: usize, pub range: usize, } /// Auto-detect importer: takes a JSON file containing either /// 1. A flat `[ { row… }, { row… }, … ]` array where each row may be a /// fixed asset (has `coord`) or a range asset (has `start_coord_lat` /// etc.); both shapes can be mixed in one file. /// 2. The round-trip export shape `{ "fixed": [...], "range": [...] }`. /// Each row is dispatched per-shape into the same INSERT/ON CONFLICT logic /// the type-specific importers use, so re-import preserve-edits semantics /// behave identically. #[tauri::command] pub async fn import_assets_auto( path: String, state: State<'_, AppState>, ) -> Result { let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?; let raw: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; // Two shapes: a top-level array (mixed rows) or the export object. let rows: Vec = match raw { serde_json::Value::Array(a) => a, serde_json::Value::Object(map) => { let mut out: Vec = Vec::new(); if let Some(serde_json::Value::Array(a)) = map.get("fixed").cloned() { out.extend(a); } if let Some(serde_json::Value::Array(a)) = map.get("range").cloned() { out.extend(a); } if out.is_empty() { return Err( "expected an array of rows or an object with `fixed` / `range` arrays" .into(), ); } out } _ => return Err("expected JSON array or object".into()), }; let mut fixed_count = 0usize; let mut range_count = 0usize; let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; // Tighter range detection: presence of `start_coord_*` / `end_coord_*` // keys isn't enough — many pipelines emit a SHARED schema where those // fields exist on every row but are empty strings on fixed assets // ("start_coord_lat": ""). Treat the row as range only when all four // start/end coord fields parse to a finite number. fn coord_field_valid(v: Option<&serde_json::Value>) -> bool { match v { Some(serde_json::Value::Number(n)) => { n.as_f64().map(|x| x.is_finite()).unwrap_or(false) } Some(serde_json::Value::String(s)) => { let t = s.trim(); !t.is_empty() && t.parse::().map(|x| x.is_finite()).unwrap_or(false) } _ => false, } } for row in rows { let is_range = coord_field_valid(row.get("start_coord_lat")) && coord_field_valid(row.get("start_coord_lng")) && coord_field_valid(row.get("end_coord_lat")) && coord_field_valid(row.get("end_coord_lng")); if is_range { let input: RangeAssetInput = serde_json::from_value(row.clone()).map_err(|e| e.to_string())?; let row_id = input.row_id.clone(); insert_range_row(&mut tx, &input).await?; ingest_bboxes_for_row(&mut tx, &row_id, &row, true).await?; range_count += 1; } else { let input: FixedAssetInput = serde_json::from_value(row.clone()).map_err(|e| e.to_string())?; let row_id = input.row_id.clone(); insert_fixed_row(&mut tx, &input).await?; ingest_bboxes_for_row(&mut tx, &row_id, &row, false).await?; fixed_count += 1; } } tx.commit().await.map_err(|e| e.to_string())?; stamp_orig_positions(&state.pool).await?; apply_scope_after_change(&state.pool).await?; Ok(AutoImportResult { fixed: fixed_count, range: range_count, }) } async fn apply_scope_after_change(pool: &SqlitePool) -> Result<(), String> { let geoms = scope::load_geoms(pool).await.map_err(|e| e.to_string())?; if geoms.is_empty() { return Ok(()); } scope::apply_scope_to_assets(pool, &geoms) .await .map(|_| ()) .map_err(|e| e.to_string()) } #[derive(Debug, Serialize)] pub struct ScopePolygonOut { pub id: i64, pub name: Option, pub geojson: String, pub geom_type: String, } #[tauri::command] pub async fn import_kml_scope( path: String, state: State<'_, AppState>, ) -> Result { let parsed = scope::parse_kml(Path::new(&path)) .await .map_err(|e| e.to_string())?; scope::replace_scope(&state.pool, &parsed) .await .map_err(|e| e.to_string())?; let geoms: Vec<_> = parsed.iter().map(|p| p.geom.clone()).collect(); scope::apply_scope_to_assets(&state.pool, &geoms) .await .map_err(|e| e.to_string())?; Ok(parsed.len()) } /// Read a multi-video metadata polyline file shaped as /// `{ video_name: [[[lat, lng], ...]] }`. Returns a map from video_name to a /// `[lng, lat]` track (deck.gl / GeoJSON convention). #[tauri::command] pub async fn read_video_polylines_json( path: String, ) -> Result>, String> { let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?; let parsed: serde_json::Map = serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON: {}", e))?; let mut out: std::collections::HashMap> = std::collections::HashMap::new(); for (video_name, val) in parsed { let outer = match val.as_array() { Some(a) if !a.is_empty() => a, _ => continue, }; let pts_json = match outer[0].as_array() { Some(a) => a, None => continue, }; let mut track: Vec<[f64; 2]> = Vec::with_capacity(pts_json.len()); for pt in pts_json { let arr = match pt.as_array() { Some(a) if a.len() >= 2 => a, _ => continue, }; // Source is [lat, lng]; downstream code expects [lng, lat]. let lat = arr[0].as_f64(); let lng = arr[1].as_f64(); if let (Some(la), Some(lo)) = (lat, lng) { // Drop sentinel / garbage coords. Anything within ~100 m of // the (0,0) origin is the standard "no GPS fix" placeholder. if la.abs() < 1e-3 && lo.abs() < 1e-3 { continue; } if !la.is_finite() || !lo.is_finite() { continue; } if !(-90.0..=90.0).contains(&la) || !(-180.0..=180.0).contains(&lo) { continue; } // Break implausible jumps (> ~5 km between consecutive points). // Most consumer GPS tracks sample faster than that; a long jump // signals a missing point, so skip it rather than draw a line // across the map. if let Some(&[plng, plat]) = track.last() { let dlat = la - plat; let dlng = lo - plng; // Cheap planar approximation in degrees → meters. let m_per_deg_lat = 111_000.0; let m_per_deg_lng = 111_000.0 * la.to_radians().cos().max(0.1); let dist = ((dlat * m_per_deg_lat).powi(2) + (dlng * m_per_deg_lng).powi(2)) .sqrt(); if dist > 5_000.0 { continue; } } track.push([lo, la]); } } if track.len() >= 2 { out.insert(video_name, track); } } if out.is_empty() { return Err("no usable polylines in file".to_string()); } Ok(out) } #[tauri::command] pub async fn get_scope_polygons( state: State<'_, AppState>, ) -> Result, String> { let rows: Vec<(i64, Option, String, String)> = sqlx::query_as("SELECT id, name, geojson, geom_type FROM scope_polygons") .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; Ok(rows .into_iter() .map(|(id, name, geojson, geom_type)| ScopePolygonOut { id, name, geojson, geom_type, }) .collect()) } #[tauri::command] pub async fn update_asset_position( id: i64, vertex: String, lat: f64, lng: f64, state: State<'_, AppState>, ) -> Result<(), String> { let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let row = sqlx::query_as::<_, Asset>( "SELECT id, row_id, asset_type, asset_name, video_name, lat, lng, start_lat, start_lng, end_lat, end_lng, image_path, image_path1, image_path2, image_path3, deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked FROM assets WHERE id = ?", ) .bind(id) .fetch_one(&mut *tx) .await .map_err(|e| e.to_string())?; let before_json = serde_json::to_string(&row).map_err(|e| e.to_string())?; let (new_lat, new_lng, new_slat, new_slng, new_elat, new_elng) = match vertex.as_str() { "fixed" | "mid" => ( Some(lat), Some(lng), row.start_lat, row.start_lng, row.end_lat, row.end_lng, ), "start" => ( row.lat, row.lng, Some(lat), Some(lng), row.end_lat, row.end_lng, ), "end" => ( row.lat, row.lng, row.start_lat, row.start_lng, Some(lat), Some(lng), ), other => return Err(format!("invalid vertex: {}", other)), }; let lats: Vec = [new_lat, new_slat, new_elat].into_iter().flatten().collect(); let lngs: Vec = [new_lng, new_slng, new_elng].into_iter().flatten().collect(); let min_lat = lats.iter().cloned().fold(f64::INFINITY, f64::min); let max_lat = lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max); let min_lng = lngs.iter().cloned().fold(f64::INFINITY, f64::min); let max_lng = lngs.iter().cloned().fold(f64::NEG_INFINITY, f64::max); sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(new_lat) .bind(new_lng) .bind(new_slat) .bind(new_slng) .bind(new_elat) .bind(new_elng) .bind(min_lat) .bind(max_lat) .bind(min_lng) .bind(max_lng) .bind(user_id()) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; let after_json = serde_json::to_string(&MoveAfter { vertex: vertex.clone(), lat, lng, }) .map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts) VALUES ('move', 'single', ?, ?, ?, ?, datetime('now'))", ) .bind(format!("[{}]", id)) .bind(user_id()) .bind(&before_json) .bind(&after_json) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; apply_scope_after_change(&state.pool).await?; Ok(()) } #[derive(Debug, serde::Deserialize)] pub struct TranslateCorrection { pub id: i64, pub dlat: f64, pub dlng: f64, } #[tauri::command] pub async fn bulk_translate( corrections: Vec, state: State<'_, AppState>, ) -> Result { if corrections.is_empty() { return Ok(0); } let ids: Vec = corrections.iter().map(|c| c.id).collect(); // SQLite caps bind params at 999; chunk the prefetch SELECT. let mut rows: Vec = Vec::with_capacity(ids.len()); for chunk in ids.chunks(500) { let placeholders = chunk.iter().map(|_| "?").collect::>().join(","); let sql = format!( "SELECT id, row_id, asset_type, asset_name, video_name, lat, lng, start_lat, start_lng, end_lat, end_lng, image_path, image_path1, image_path2, image_path3, deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked FROM assets WHERE id IN ({})", placeholders ); let mut q = sqlx::query_as::<_, Asset>(&sql); for id in chunk { q = q.bind(id); } let part: Vec = q .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; rows.extend(part); } use std::collections::HashMap; let by_id: HashMap = rows.iter().map(|a| (a.id, a)).collect(); let mut before_map = serde_json::Map::new(); let mut after_map = serde_json::Map::new(); let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut changed = 0usize; for c in &corrections { let Some(row) = by_id.get(&c.id) else { continue }; before_map.insert( c.id.to_string(), serde_json::json!({ "lat": row.lat, "lng": row.lng, "start_lat": row.start_lat, "start_lng": row.start_lng, "end_lat": row.end_lat, "end_lng": row.end_lng, "modified": row.modified, }), ); let new_lat = row.lat.map(|v| v + c.dlat); let new_lng = row.lng.map(|v| v + c.dlng); let new_slat = row.start_lat.map(|v| v + c.dlat); let new_slng = row.start_lng.map(|v| v + c.dlng); let new_elat = row.end_lat.map(|v| v + c.dlat); let new_elng = row.end_lng.map(|v| v + c.dlng); let lats: Vec = [new_lat, new_slat, new_elat] .into_iter() .flatten() .collect(); let lngs: Vec = [new_lng, new_slng, new_elng] .into_iter() .flatten() .collect(); if lats.is_empty() || lngs.is_empty() { continue; } let mn_lat = lats.iter().cloned().fold(f64::INFINITY, f64::min); let mx_lat = lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max); let mn_lng = lngs.iter().cloned().fold(f64::INFINITY, f64::min); let mx_lng = lngs.iter().cloned().fold(f64::NEG_INFINITY, f64::max); sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(new_lat) .bind(new_lng) .bind(new_slat) .bind(new_slng) .bind(new_elat) .bind(new_elng) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(user_id()) .bind(c.id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; after_map.insert( c.id.to_string(), serde_json::json!({ "lat": new_lat, "lng": new_lng, "start_lat": new_slat, "start_lng": new_slng, "end_lat": new_elat, "end_lng": new_elng, "modified": 1, }), ); changed += 1; } if changed == 0 { return Ok(0); } let ids_json = serde_json::to_string(&ids).map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts) VALUES ('bulk_translate', 'bulk', ?, ?, ?, ?, datetime('now'))", ) .bind(&ids_json) .bind(user_id()) .bind(serde_json::to_string(&before_map).map_err(|e| e.to_string())?) .bind(serde_json::to_string(&after_map).map_err(|e| e.to_string())?) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; apply_scope_after_change(&state.pool).await?; Ok(changed) } #[tauri::command] pub async fn reset_assets_to_original( ids: Vec, state: State<'_, AppState>, ) -> Result { if ids.is_empty() { return Ok(0); } // SQLite caps bind params at 999; chunk both SELECTs. let mut rows: Vec = Vec::with_capacity(ids.len()); for chunk in ids.chunks(500) { let placeholders = chunk.iter().map(|_| "?").collect::>().join(","); let select_sql = format!( "SELECT id, row_id, asset_type, asset_name, video_name, lat, lng, start_lat, start_lng, end_lat, end_lng, image_path, image_path1, image_path2, image_path3, deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked FROM assets WHERE id IN ({})", placeholders ); let mut q = sqlx::query_as::<_, Asset>(&select_sql); for id in chunk { q = q.bind(id); } let part: Vec = q .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; rows.extend(part); } // Snapshot before & fetch originals (chunked the same way). type OrigRow = ( i64, Option, Option, Option, Option, Option, Option, String, ); let mut origs: Vec = Vec::with_capacity(ids.len()); for chunk in ids.chunks(500) { let placeholders = chunk.iter().map(|_| "?").collect::>().join(","); let orig_sql = format!( "SELECT id, orig_lat, orig_lng, orig_start_lat, orig_start_lng, orig_end_lat, orig_end_lng, asset_type FROM assets WHERE id IN ({})", placeholders ); let mut oq = sqlx::query_as::<_, OrigRow>(&orig_sql); for id in chunk { oq = oq.bind(id); } let part = oq .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; origs.extend(part); } use std::collections::HashMap; let orig_map: HashMap, Option, Option, Option, Option, Option, String)> = origs .into_iter() .map(|(id, a, b, c, d, e, f, t)| (id, (a, b, c, d, e, f, t))) .collect(); let mut before_map = serde_json::Map::new(); let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut changed = 0usize; for row in &rows { let Some(o) = orig_map.get(&row.id) else { continue }; // No original recorded (asset created before this column existed): skip. let has_orig = o.0.is_some() || o.2.is_some() || o.4.is_some(); if !has_orig { continue; } let (new_lat, new_lng, new_slat, new_slng, new_elat, new_elng) = if o.6 == "range" { (o.0, o.1, o.2, o.3, o.4, o.5) } else { (o.0, o.1, None, None, None, None) }; before_map.insert( row.id.to_string(), serde_json::json!({ "lat": row.lat, "lng": row.lng, "start_lat": row.start_lat, "start_lng": row.start_lng, "end_lat": row.end_lat, "end_lng": row.end_lng, "modified": row.modified, }), ); let lats: Vec = [new_lat, new_slat, new_elat] .into_iter() .flatten() .collect(); let lngs: Vec = [new_lng, new_slng, new_elng] .into_iter() .flatten() .collect(); if lats.is_empty() || lngs.is_empty() { continue; } let mn_lat = lats.iter().cloned().fold(f64::INFINITY, f64::min); let mx_lat = lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max); let mn_lng = lngs.iter().cloned().fold(f64::INFINITY, f64::min); let mx_lng = lngs.iter().cloned().fold(f64::NEG_INFINITY, f64::max); sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = 0, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(new_lat) .bind(new_lng) .bind(new_slat) .bind(new_slng) .bind(new_elat) .bind(new_elng) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(user_id()) .bind(row.id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; changed += 1; } if changed == 0 { return Ok(0); } let ids_json = serde_json::to_string(&ids).map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, ts) VALUES ('reset_position', 'bulk', ?, ?, ?, datetime('now'))", ) .bind(&ids_json) .bind(user_id()) .bind(serde_json::to_string(&before_map).map_err(|e| e.to_string())?) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; apply_scope_after_change(&state.pool).await?; Ok(changed) } #[tauri::command] pub async fn delete_asset(id: i64, state: State<'_, AppState>) -> Result<(), String> { let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let res = sqlx::query("UPDATE assets SET deleted = 1, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ? AND deleted = 0") .bind(user_id()) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; if res.rows_affected() == 0 { return Err("asset not found or already deleted".to_string()); } sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, ts) VALUES ('delete', 'single', ?, ?, datetime('now'))", ) .bind(format!("[{}]", id)) .bind(user_id()) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn delete_assets_by_video( video_name: String, state: State<'_, AppState>, ) -> Result { let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let ids: Vec<(i64,)> = sqlx::query_as( "SELECT id FROM assets WHERE video_name = ? AND deleted = 0", ) .bind(&video_name) .fetch_all(&mut *tx) .await .map_err(|e| e.to_string())?; if ids.is_empty() { return Ok(0); } let id_list: Vec = ids.iter().map(|(i,)| *i).collect(); sqlx::query("UPDATE assets SET deleted = 1, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE video_name = ? AND deleted = 0") .bind(user_id()) .bind(&video_name) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; let ids_json = serde_json::to_string(&id_list).map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, ts) VALUES ('delete_by_video', 'video', ?, ?, ?, datetime('now'))", ) .bind(&ids_json) .bind(user_id()) .bind(&video_name) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; Ok(id_list.len()) } #[tauri::command] pub async fn merge_polylines( primary_id: i64, secondary_id: i64, state: State<'_, AppState>, ) -> Result<(), String> { if primary_id == secondary_id { return Err("primary and secondary must differ".to_string()); } let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let primary: Asset = sqlx::query_as( "SELECT id, row_id, asset_type, asset_name, video_name, lat, lng, start_lat, start_lng, end_lat, end_lng, image_path, image_path1, image_path2, image_path3, deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked FROM assets WHERE id = ?", ) .bind(primary_id) .fetch_one(&mut *tx) .await .map_err(|e| e.to_string())?; let secondary: Asset = sqlx::query_as( "SELECT id, row_id, asset_type, asset_name, video_name, lat, lng, start_lat, start_lng, end_lat, end_lng, image_path, image_path1, image_path2, image_path3, deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked FROM assets WHERE id = ?", ) .bind(secondary_id) .fetch_one(&mut *tx) .await .map_err(|e| e.to_string())?; if primary.asset_type != "range" || secondary.asset_type != "range" { return Err("both assets must be range".to_string()); } if secondary.merged_into.is_some() { return Err("secondary is already merged".to_string()); } if secondary.deleted == 1 { return Err("secondary is deleted".to_string()); } // Snapshot primary BEFORE we modify its geometry — needed to undo a merge. let primary_before = serde_json::to_string(&primary).map_err(|e| e.to_string())?; // Combine geometry: pick the closest pair of endpoints (one from each polyline) // as the "join" — the OTHER two endpoints become the new outer start/end. let p_start = ( primary.start_lat.ok_or("primary has no start_lat")?, primary.start_lng.ok_or("primary has no start_lng")?, ); let p_end = ( primary.end_lat.ok_or("primary has no end_lat")?, primary.end_lng.ok_or("primary has no end_lng")?, ); let s_start = ( secondary.start_lat.ok_or("secondary has no start_lat")?, secondary.start_lng.ok_or("secondary has no start_lng")?, ); let s_end = ( secondary.end_lat.ok_or("secondary has no end_lat")?, secondary.end_lng.ok_or("secondary has no end_lng")?, ); let dist = |a: (f64, f64), b: (f64, f64)| { Geodesic.distance(Point::new(a.1, a.0), Point::new(b.1, b.0)) }; let d_ss = dist(p_start, s_start); let d_se = dist(p_start, s_end); let d_es = dist(p_end, s_start); let d_ee = dist(p_end, s_end); let min_d = d_ss.min(d_se).min(d_es).min(d_ee); let (new_start, new_end) = if (min_d - d_ss).abs() < 1e-9 { (p_end, s_end) } else if (min_d - d_se).abs() < 1e-9 { (p_end, s_start) } else if (min_d - d_es).abs() < 1e-9 { (p_start, s_end) } else { (p_start, s_start) }; let new_mid = ( (new_start.0 + new_end.0) / 2.0, (new_start.1 + new_end.1) / 2.0, ); let mn_lat = new_start.0.min(new_mid.0).min(new_end.0); let mx_lat = new_start.0.max(new_mid.0).max(new_end.0); let mn_lng = new_start.1.min(new_mid.1).min(new_end.1); let mx_lng = new_start.1.max(new_mid.1).max(new_end.1); // 1) extend primary to combined geometry sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(new_mid.0) .bind(new_mid.1) .bind(new_start.0) .bind(new_start.1) .bind(new_end.0) .bind(new_end.1) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(user_id()) .bind(primary_id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; // 2) hide secondary, link to primary sqlx::query( "UPDATE assets SET merged_into = ?, deleted = 1, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(primary_id) .bind(user_id()) .bind(secondary_id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; // `after` carries the merged primary's geometry so redo can re-apply // it without recomputing. Without this, redo would only re-delete the // secondary and leave the primary at its pre-merge geometry. let after_json = serde_json::to_string(&serde_json::json!({ "primary_id": primary_id, "lat": new_mid.0, "lng": new_mid.1, "start_lat": new_start.0, "start_lng": new_start.1, "end_lat": new_end.0, "end_lng": new_end.1, "min_lat": mn_lat, "max_lat": mx_lat, "min_lng": mn_lng, "max_lng": mx_lng, })) .map_err(|e| e.to_string())?; let ids_json = serde_json::to_string(&[primary_id, secondary_id]).map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts) VALUES ('merge', 'merge', ?, ?, ?, ?, datetime('now'))", ) .bind(&ids_json) .bind(user_id()) .bind(&primary_before) .bind(&after_json) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn undo_action(action_id: i64, state: State<'_, AppState>) -> Result<(), String> { let row: AuditRow = sqlx::query_as("SELECT id, action_type, scope, asset_ids, user_id, before, after, ts, undone FROM action_history WHERE id = ?") .bind(action_id) .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; if row.undone == 1 { return Err("action already undone".to_string()); } let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; match row.action_type.as_str() { // snap_single shares the "before" shape with move (full Asset snapshot). "move" | "snap_single" => { let snap: Asset = serde_json::from_str( row.before.as_deref().unwrap_or("{}"), ) .map_err(|e| e.to_string())?; sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = ?, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(snap.lat) .bind(snap.lng) .bind(snap.start_lat) .bind(snap.start_lng) .bind(snap.end_lat) .bind(snap.end_lng) // Recompute bbox from snapshot .bind(min_or_nan(&[snap.lat, snap.start_lat, snap.end_lat])) .bind(max_or_nan(&[snap.lat, snap.start_lat, snap.end_lat])) .bind(min_or_nan(&[snap.lng, snap.start_lng, snap.end_lng])) .bind(max_or_nan(&[snap.lng, snap.start_lng, snap.end_lng])) .bind(snap.modified) .bind(user_id()) .bind(snap.id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } "delete" | "delete_by_video" => { let ids: Vec = serde_json::from_str(&row.asset_ids).map_err(|e| e.to_string())?; for id in ids { sqlx::query("UPDATE assets SET deleted = 0, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?") .bind(user_id()) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "set_scope" => { let before_map: serde_json::Map = serde_json::from_str(row.before.as_deref().unwrap_or("{}")) .map_err(|e| e.to_string())?; for (id_str, val) in before_map { let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?; let in_scope = val.get("in_scope").and_then(|v| v.as_i64()); let manual = val.get("manual_scope").and_then(|v| v.as_i64()); sqlx::query( "UPDATE assets SET in_scope = ?, manual_scope = ? WHERE id = ?", ) .bind(in_scope) .bind(manual) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "rename" => { let before_map: serde_json::Map = serde_json::from_str(row.before.as_deref().unwrap_or("{}")) .map_err(|e| e.to_string())?; for (id_str, val) in before_map { let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?; let prior = val .get("asset_name") .and_then(|v| v.as_str()) .unwrap_or(""); sqlx::query("UPDATE assets SET asset_name = ? WHERE id = ?") .bind(prior) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "change_side" => { let before_map: serde_json::Map = serde_json::from_str(row.before.as_deref().unwrap_or("{}")) .map_err(|e| e.to_string())?; for (id_str, val) in before_map { let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?; let prior = val.get("side").and_then(|v| v.as_str()).map(String::from); sqlx::query("UPDATE assets SET side = ? WHERE id = ?") .bind(&prior) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "link" => { let before_map: serde_json::Map = serde_json::from_str(row.before.as_deref().unwrap_or("{}")) .map_err(|e| e.to_string())?; for (id_str, val) in before_map { let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?; let pair = val.get("link_pair_id").and_then(|v| v.as_i64()); let locked = val .get("link_locked") .and_then(|v| v.as_i64()) .unwrap_or(0); sqlx::query( "UPDATE assets SET link_pair_id = ?, link_locked = ? WHERE id = ?", ) .bind(pair) .bind(locked) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "bulk_translate" => { // Same shape as reset_position: before_map keyed by id with // pre-edit lat/lng/start/end. Restore each row. let before_map: serde_json::Map = serde_json::from_str(row.before.as_deref().unwrap_or("{}")) .map_err(|e| e.to_string())?; for (id_str, val) in before_map { let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?; let lat = val.get("lat").and_then(|v| v.as_f64()); let lng = val.get("lng").and_then(|v| v.as_f64()); let slat = val.get("start_lat").and_then(|v| v.as_f64()); let slng = val.get("start_lng").and_then(|v| v.as_f64()); let elat = val.get("end_lat").and_then(|v| v.as_f64()); let elng = val.get("end_lng").and_then(|v| v.as_f64()); let modified = val.get("modified").and_then(|v| v.as_i64()).unwrap_or(0); let lats: Vec = [lat, slat, elat].into_iter().flatten().collect(); let lngs: Vec = [lng, slng, elng].into_iter().flatten().collect(); if lats.is_empty() || lngs.is_empty() { continue; } let mn_lat = lats.iter().cloned().fold(f64::INFINITY, f64::min); let mx_lat = lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max); let mn_lng = lngs.iter().cloned().fold(f64::INFINITY, f64::min); let mx_lng = lngs.iter().cloned().fold(f64::NEG_INFINITY, f64::max); sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = ?, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(lat) .bind(lng) .bind(slat) .bind(slng) .bind(elat) .bind(elng) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(modified) .bind(user_id()) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "reset_position" => { let before_map: serde_json::Map = serde_json::from_str(row.before.as_deref().unwrap_or("{}")) .map_err(|e| e.to_string())?; for (id_str, val) in before_map { let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?; let lat = val.get("lat").and_then(|v| v.as_f64()); let lng = val.get("lng").and_then(|v| v.as_f64()); let slat = val.get("start_lat").and_then(|v| v.as_f64()); let slng = val.get("start_lng").and_then(|v| v.as_f64()); let elat = val.get("end_lat").and_then(|v| v.as_f64()); let elng = val.get("end_lng").and_then(|v| v.as_f64()); let modified = val.get("modified").and_then(|v| v.as_i64()).unwrap_or(0); let lats: Vec = [lat, slat, elat].into_iter().flatten().collect(); let lngs: Vec = [lng, slng, elng].into_iter().flatten().collect(); if lats.is_empty() || lngs.is_empty() { continue; } let mn_lat = lats.iter().cloned().fold(f64::INFINITY, f64::min); let mx_lat = lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max); let mn_lng = lngs.iter().cloned().fold(f64::INFINITY, f64::min); let mx_lng = lngs.iter().cloned().fold(f64::NEG_INFINITY, f64::max); sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = ?, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(lat) .bind(lng) .bind(slat) .bind(slng) .bind(elat) .bind(elng) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(modified) .bind(user_id()) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "restore" => { // Inverse of restore = re-delete. let ids: Vec = serde_json::from_str(&row.asset_ids) .map_err(|e| e.to_string())?; for id in ids { sqlx::query("UPDATE assets SET deleted = 1 WHERE id = ?") .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "merge" => { // asset_ids = [primary_id, secondary_id]; before = full primary snapshot. let ids: Vec = serde_json::from_str(&row.asset_ids).map_err(|e| e.to_string())?; if ids.len() != 2 { return Err("malformed merge audit row".to_string()); } let primary_id = ids[0]; let secondary_id = ids[1]; // Restore primary geometry from snapshot. let snap: Asset = serde_json::from_str(row.before.as_deref().unwrap_or("{}")) .map_err(|e| e.to_string())?; let mn_lat = min_or_nan(&[snap.lat, snap.start_lat, snap.end_lat]); let mx_lat = max_or_nan(&[snap.lat, snap.start_lat, snap.end_lat]); let mn_lng = min_or_nan(&[snap.lng, snap.start_lng, snap.end_lng]); let mx_lng = max_or_nan(&[snap.lng, snap.start_lng, snap.end_lng]); sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = ?, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(snap.lat) .bind(snap.lng) .bind(snap.start_lat) .bind(snap.start_lng) .bind(snap.end_lat) .bind(snap.end_lng) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(snap.modified) .bind(user_id()) .bind(primary_id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; // Restore secondary's flags. sqlx::query( "UPDATE assets SET merged_into = NULL, deleted = 0, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(user_id()) .bind(secondary_id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } "bulk_snap" => { let before_map: serde_json::Map = serde_json::from_str(row.before.as_deref().unwrap_or("{}")) .map_err(|e| e.to_string())?; for (id_str, snap_val) in before_map { let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?; let snap: Asset = serde_json::from_value(snap_val).map_err(|e| e.to_string())?; let mn_lat = min_or_nan(&[snap.lat, snap.start_lat, snap.end_lat]); let mx_lat = max_or_nan(&[snap.lat, snap.start_lat, snap.end_lat]); let mn_lng = min_or_nan(&[snap.lng, snap.start_lng, snap.end_lng]); let mx_lng = max_or_nan(&[snap.lng, snap.start_lng, snap.end_lng]); sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = ?, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(snap.lat) .bind(snap.lng) .bind(snap.start_lat) .bind(snap.start_lng) .bind(snap.end_lat) .bind(snap.end_lng) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(snap.modified) .bind(user_id()) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } other => return Err(format!("unknown action_type: {}", other)), } sqlx::query("UPDATE action_history SET undone = 1 WHERE id = ?") .bind(action_id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; apply_scope_after_change(&state.pool).await?; Ok(()) } fn min_or_nan(vals: &[Option]) -> f64 { vals.iter() .filter_map(|v| *v) .fold(f64::INFINITY, f64::min) } fn max_or_nan(vals: &[Option]) -> f64 { vals.iter() .filter_map(|v| *v) .fold(f64::NEG_INFINITY, f64::max) } #[derive(Debug, Deserialize)] struct GeoJsonFeatureCollection { features: Vec, } #[derive(Debug, Deserialize)] struct GeoJsonFeature { geometry: serde_json::Value, #[serde(default)] properties: serde_json::Value, } #[derive(Debug, Serialize)] pub struct RoadOut { pub id: i64, pub name: Option, pub geojson: String, pub snap_ignored: bool, // True if the road's geometry has been touched since import. Drives the // green highlight in the OSM layer so users can see what they've changed. pub modified: bool, } // Generic over the executor: pass &state.pool for reads taken before any // mutation, or &mut *tx for reads that must see the in-flight UPDATE inside // the same transaction. Without this, a 4-connection pool reads a different // connection that hasn't observed the uncommitted write yet, producing // stale `after` snapshots and broken redo. // Takes &mut SqliteConnection so callers can reborrow inside chunks. // Pool callers pass &mut *(pool.acquire().await?); tx callers pass &mut *tx. // This lets in-tx snapshots see the UPDATE that's already happened in the // same transaction (the previous &state.pool path read a *different* // pool-connection that hadn't observed the uncommitted write). // Chunked at 500 to stay clear of SQLite's 999 bind-parameter ceiling. async fn snapshot_scope_state( conn: &mut sqlx::SqliteConnection, ids: &[i64], ) -> Result, String> { let mut m = serde_json::Map::new(); if ids.is_empty() { return Ok(m); } for chunk in ids.chunks(500) { let placeholders = vec!["?"; chunk.len()].join(","); let sql = format!( "SELECT id, in_scope, manual_scope FROM assets WHERE id IN ({})", placeholders ); let mut q = sqlx::query_as::<_, (i64, Option, Option)>(&sql); for id in chunk { q = q.bind(id); } let rows = q.fetch_all(&mut *conn).await.map_err(|e| e.to_string())?; for (id, in_scope, manual) in rows { m.insert( id.to_string(), serde_json::json!({"in_scope": in_scope, "manual_scope": manual}), ); } } Ok(m) } #[tauri::command] pub async fn set_assets_in_scope( ids: Vec, in_scope: bool, state: State<'_, AppState>, ) -> Result { if ids.is_empty() { return Ok(0); } let mut conn = state.pool.acquire().await.map_err(|e| e.to_string())?; let before = snapshot_scope_state(&mut conn, &ids).await?; drop(conn); let placeholders = vec!["?"; ids.len()].join(","); let sql = format!( "UPDATE assets SET in_scope = ?, manual_scope = ?, modified_by = ?, \ modified_at = datetime('now') WHERE id IN ({})", placeholders ); let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut q = sqlx::query(&sql) .bind(in_scope as i64) .bind(in_scope as i64) .bind(user_id()); for id in &ids { q = q.bind(id); } let res = q.execute(&mut *tx).await.map_err(|e| e.to_string())?; // Read `after` from inside the same tx so the snapshot reflects the // UPDATE we just executed. let after = snapshot_scope_state(&mut *tx, &ids).await?; let ids_json = serde_json::to_string(&ids).map_err(|e| e.to_string())?; let before_json = serde_json::to_string(&before).map_err(|e| e.to_string())?; let after_json = serde_json::to_string(&after).map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts) VALUES ('set_scope', 'bulk', ?, ?, ?, ?, datetime('now'))", ) .bind(&ids_json) .bind(user_id()) .bind(&before_json) .bind(&after_json) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; Ok(res.rows_affected() as usize) } #[tauri::command] pub async fn clear_manual_scope( ids: Vec, state: State<'_, AppState>, ) -> Result { if ids.is_empty() { return Ok(0); } let mut conn = state.pool.acquire().await.map_err(|e| e.to_string())?; let before = snapshot_scope_state(&mut conn, &ids).await?; drop(conn); let placeholders = vec!["?"; ids.len()].join(","); let sql = format!( "UPDATE assets SET manual_scope = NULL WHERE id IN ({})", placeholders ); let mut q = sqlx::query(&sql); for id in &ids { q = q.bind(id); } let res = q.execute(&state.pool).await.map_err(|e| e.to_string())?; apply_scope_after_change(&state.pool).await?; let mut conn = state.pool.acquire().await.map_err(|e| e.to_string())?; let after = snapshot_scope_state(&mut conn, &ids).await?; drop(conn); let ids_json = serde_json::to_string(&ids).map_err(|e| e.to_string())?; let before_json = serde_json::to_string(&before).map_err(|e| e.to_string())?; let after_json = serde_json::to_string(&after).map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts) VALUES ('set_scope', 'bulk', ?, ?, ?, ?, datetime('now'))", ) .bind(&ids_json) .bind(user_id()) .bind(&before_json) .bind(&after_json) .execute(&state.pool) .await .map_err(|e| e.to_string())?; Ok(res.rows_affected() as usize) } #[tauri::command] pub async fn set_assets_by_video_in_scope( video_name: String, in_scope: bool, state: State<'_, AppState>, ) -> Result { let rows: Vec<(i64,)> = sqlx::query_as( "SELECT id FROM assets WHERE video_name = ? AND deleted = 0", ) .bind(&video_name) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; let ids: Vec = rows.into_iter().map(|(id,)| id).collect(); set_assets_in_scope(ids, in_scope, state).await } #[tauri::command] pub async fn delete_assets_bulk( ids: Vec, state: State<'_, AppState>, ) -> Result { if ids.is_empty() { return Ok(0); } // SQLite caps bind params at 999; chunk into batches. let mut assets: Vec = Vec::with_capacity(ids.len()); for chunk in ids.chunks(500) { let placeholders = chunk.iter().map(|_| "?").collect::>().join(","); let before_sql = format!( "SELECT id, row_id, asset_type, asset_name, video_name, lat, lng, \ start_lat, start_lng, end_lat, end_lng, \ image_path, image_path1, image_path2, image_path3, \ deleted, modified, merged_into, in_scope, side, \ link_pair_id, link_locked \ FROM assets WHERE id IN ({}) AND deleted = 0", placeholders ); let mut bq = sqlx::query_as::<_, Asset>(&before_sql); for id in chunk { bq = bq.bind(id); } let part = bq.fetch_all(&state.pool).await.map_err(|e| e.to_string())?; assets.extend(part); } if assets.is_empty() { return Ok(0); } let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut total_affected: u64 = 0; for chunk in ids.chunks(500) { let placeholders = chunk.iter().map(|_| "?").collect::>().join(","); let upd_sql = format!( "UPDATE assets SET deleted = 1, modified = 1, modified_by = ?, \ modified_at = datetime('now') WHERE id IN ({}) AND deleted = 0", placeholders ); let mut uq = sqlx::query(&upd_sql).bind(user_id()); for id in chunk { uq = uq.bind(id); } let r = uq.execute(&mut *tx).await.map_err(|e| e.to_string())?; total_affected += r.rows_affected(); } let affected_ids: Vec = assets.iter().map(|a| a.id).collect(); let mut before_map = serde_json::Map::new(); for a in &assets { before_map.insert( a.id.to_string(), serde_json::to_value(a).unwrap_or(serde_json::Value::Null), ); } let ids_json = serde_json::to_string(&affected_ids).map_err(|e| e.to_string())?; let before_json = serde_json::to_string(&before_map).map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, ts) VALUES ('delete', 'bulk', ?, ?, ?, datetime('now'))", ) .bind(&ids_json) .bind(user_id()) .bind(&before_json) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; Ok(total_affected as usize) } #[tauri::command] pub async fn export_assets( path: String, format: String, state: State<'_, AppState>, ) -> Result { let assets: Vec = sqlx::query_as( "SELECT id, row_id, asset_type, asset_name, video_name, lat, lng, start_lat, start_lng, end_lat, end_lng, image_path, image_path1, image_path2, image_path3, deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked FROM assets WHERE deleted = 0 ORDER BY asset_type, asset_name, id", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; let body = match format.to_lowercase().as_str() { "geojson" | "json" => { let mut features = vec![]; for a in &assets { let props = serde_json::json!({ "row_id": a.row_id, "asset_type": a.asset_type, "asset_name": a.asset_name, "video_name": a.video_name, "side": a.side, "in_scope": a.in_scope, "modified": a.modified, "image_path": a.image_path, "image_path1": a.image_path1, "image_path2": a.image_path2, "image_path3": a.image_path3, "link_pair_id": a.link_pair_id, "link_locked": a.link_locked, }); if a.asset_type == "fixed" { if let (Some(la), Some(ln)) = (a.lat, a.lng) { features.push(serde_json::json!({ "type": "Feature", "geometry": {"type": "Point", "coordinates": [ln, la]}, "properties": props, })); } } else if a.asset_type == "range" { let mut coords: Vec<[f64; 2]> = vec![]; if let (Some(la), Some(ln)) = (a.start_lat, a.start_lng) { coords.push([ln, la]); } if let (Some(la), Some(ln)) = (a.lat, a.lng) { coords.push([ln, la]); } if let (Some(la), Some(ln)) = (a.end_lat, a.end_lng) { coords.push([ln, la]); } if coords.len() >= 2 { features.push(serde_json::json!({ "type": "Feature", "geometry": {"type": "LineString", "coordinates": coords}, "properties": props, })); } } } serde_json::to_string_pretty(&serde_json::json!({ "type": "FeatureCollection", "features": features, })) .map_err(|e| e.to_string())? } "kml" => { let mut s = String::new(); s.push_str("\n"); s.push_str("\n"); for a in &assets { let name = format!("{} · {}", a.asset_name, a.row_id); let desc = format!( "type: {}\nvideo: {}\nside: {}\nmodified: {}", a.asset_type, a.video_name, a.side.as_deref().unwrap_or(""), a.modified ); if a.asset_type == "fixed" { if let (Some(la), Some(ln)) = (a.lat, a.lng) { s.push_str(&format!( "{}{}\ {},{},0\n", xml_escape(&name), xml_escape(&desc), ln, la )); } } else if a.asset_type == "range" { let mut coords: Vec<(f64, f64)> = vec![]; if let (Some(la), Some(ln)) = (a.start_lat, a.start_lng) { coords.push((ln, la)); } if let (Some(la), Some(ln)) = (a.lat, a.lng) { coords.push((ln, la)); } if let (Some(la), Some(ln)) = (a.end_lat, a.end_lng) { coords.push((ln, la)); } if coords.len() >= 2 { let coord_str = coords .iter() .map(|(ln, la)| format!("{},{},0", ln, la)) .collect::>() .join(" "); s.push_str(&format!( "{}{}\ {}\n", xml_escape(&name), xml_escape(&desc), coord_str )); } } } s.push_str("\n"); s } "source" => { // Round-trippable JSON in the same shape import_*_assets accept. // Fixed and range objects are written into separate top-level keys // so the user can re-feed each via the matching importer. Includes // deleted=true rows so the audit state survives a round-trip. let mut fixed_arr: Vec = vec![]; let mut range_arr: Vec = vec![]; for a in &assets { if a.asset_type == "fixed" { if let (Some(la), Some(ln)) = (a.lat, a.lng) { fixed_arr.push(serde_json::json!({ "row_id": a.row_id, "asset_name": a.asset_name, "video_name": a.video_name, "image_path": a.image_path, "coord": [la, ln], "deleted": a.deleted != 0, "side": a.side, })); } } else if a.asset_type == "range" { if let (Some(la), Some(ln), Some(sla), Some(sln), Some(ela), Some(eln)) = (a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng) { range_arr.push(serde_json::json!({ "row_id": a.row_id, "asset_name": a.asset_name, "video_name": a.video_name, "coord_lat": la, "coord_lng": ln, "start_coord_lat": sla, "start_coord_lng": sln, "end_coord_lat": ela, "end_coord_lng": eln, "image_path1": a.image_path1, "image_path2": a.image_path2, "image_path3": a.image_path3, "deleted": a.deleted != 0, "side": a.side, })); } } } serde_json::to_string_pretty(&serde_json::json!({ "fixed": fixed_arr, "range": range_arr, })) .map_err(|e| e.to_string())? } "csv" => { let mut s = String::new(); s.push_str("id,row_id,asset_type,asset_name,video_name,side,in_scope,deleted,modified,lat,lng,start_lat,start_lng,end_lat,end_lng,link_pair_id,link_locked,image_path,image_path1,image_path2,image_path3\n"); let csv_q = |v: Option<&str>| -> String { let s = v.unwrap_or(""); if s.contains(',') || s.contains('"') || s.contains('\n') { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() } }; let f = |o: Option| o.map(|v| v.to_string()).unwrap_or_default(); let i = |o: Option| o.map(|v| v.to_string()).unwrap_or_default(); for a in &assets { s.push_str(&format!( "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n", a.id, csv_q(Some(&a.row_id)), a.asset_type, csv_q(Some(&a.asset_name)), csv_q(Some(&a.video_name)), csv_q(a.side.as_deref()), i(a.in_scope), a.deleted, a.modified, f(a.lat), f(a.lng), f(a.start_lat), f(a.start_lng), f(a.end_lat), f(a.end_lng), i(a.link_pair_id), i(a.link_locked), csv_q(a.image_path.as_deref()), csv_q(a.image_path1.as_deref()), csv_q(a.image_path2.as_deref()), csv_q(a.image_path3.as_deref()), )); } s } other => return Err(format!("unsupported format: {}", other)), }; tokio::fs::write(&path, body) .await .map_err(|e| e.to_string())?; Ok(assets.len()) } fn xml_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } #[tauri::command] pub async fn list_classes(state: State<'_, AppState>) -> Result, String> { // Read classes.txt from app dir if present; otherwise fall back to the // distinct asset_name list. Frontend uses this to populate the rename // dropdown. let path = std::env::current_dir() .map_err(|e| e.to_string())? .join("classes.txt"); if path.exists() { let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?; let s = String::from_utf8_lossy(&bytes); let names: Vec = s .lines() .map(|l| l.trim().to_string()) .filter(|l| !l.is_empty() && !l.starts_with('#')) .collect(); if !names.is_empty() { return Ok(names); } } let rows: Vec<(String,)> = sqlx::query_as( "SELECT DISTINCT asset_name FROM assets WHERE deleted = 0 ORDER BY asset_name", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; Ok(rows.into_iter().map(|(n,)| n).collect()) } #[tauri::command] pub async fn add_class(name: String) -> Result<(), String> { let path = std::env::current_dir() .map_err(|e| e.to_string())? .join("classes.txt"); let trimmed = name.trim().to_string(); if trimmed.is_empty() { return Err("class name is empty".into()); } let mut existing = if path.exists() { let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?; String::from_utf8_lossy(&bytes).to_string() } else { String::new() }; let already = existing.lines().any(|l| l.trim() == trimmed); if already { return Ok(()); } if !existing.is_empty() && !existing.ends_with('\n') { existing.push('\n'); } existing.push_str(&trimmed); existing.push('\n'); tokio::fs::write(&path, existing) .await .map_err(|e| e.to_string())?; Ok(()) } #[derive(Debug, Serialize)] pub struct AutoLinkResult { pub linked: usize, pub leftover_a: usize, pub leftover_b: usize, } // Same shape as snapshot_scope_state — see notes there. async fn snapshot_link_state( conn: &mut sqlx::SqliteConnection, ids: &[i64], ) -> Result, String> { let mut m = serde_json::Map::new(); if ids.is_empty() { return Ok(m); } for chunk in ids.chunks(500) { let placeholders = vec!["?"; chunk.len()].join(","); let sql = format!( "SELECT id, link_pair_id, link_locked FROM assets WHERE id IN ({})", placeholders ); let mut q = sqlx::query_as::<_, (i64, Option, Option)>(&sql); for id in chunk { q = q.bind(id); } let rows = q.fetch_all(&mut *conn).await.map_err(|e| e.to_string())?; for (id, pair, locked) in rows { m.insert( id.to_string(), serde_json::json!({"link_pair_id": pair, "link_locked": locked.unwrap_or(0)}), ); } } Ok(m) } fn haversine_m(a: (f64, f64), b: (f64, f64)) -> f64 { let r = 6_371_000.0_f64; let to_rad = |d: f64| d * std::f64::consts::PI / 180.0; let d_lat = to_rad(b.0 - a.0); let d_lng = to_rad(b.1 - a.1); let s = (d_lat / 2.0).sin().powi(2) + to_rad(a.0).cos() * to_rad(b.0).cos() * (d_lng / 2.0).sin().powi(2); 2.0 * r * s.sqrt().asin() } fn point_in_polygon(lng: f64, lat: f64, verts: &[[f64; 2]]) -> bool { if verts.len() < 3 { return false; } let mut inside = false; let n = verts.len(); let mut j = n - 1; for i in 0..n { let xi = verts[i][0]; let yi = verts[i][1]; let xj = verts[j][0]; let yj = verts[j][1]; let intersect = (yi > lat) != (yj > lat) && lng < (xj - xi) * (lat - yi) / (yj - yi + 1e-12) + xi; if intersect { inside = !inside; } j = i; } inside } #[tauri::command] pub async fn auto_link_in_polygon( polygon: Vec<[f64; 2]>, split_by: String, max_distance_m: f64, state: State<'_, AppState>, ) -> Result { if polygon.len() < 3 { return Err("polygon needs ≥ 3 vertices".into()); } if max_distance_m <= 0.0 { return Err("max_distance_m must be > 0".into()); } let mn_lat = polygon.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min); let mx_lat = polygon.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max); let mn_lng = polygon.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min); let mx_lng = polygon.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max); let candidates: Vec = sqlx::query_as( "SELECT a.id, a.row_id, a.asset_type, a.asset_name, a.video_name, a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng, a.image_path, a.image_path1, a.image_path2, a.image_path3, a.deleted, a.modified, a.merged_into, a.in_scope, a.side, a.link_pair_id, a.link_locked FROM assets a JOIN assets_rtree r ON r.id = a.id WHERE r.max_lat >= ? AND r.min_lat <= ? AND r.max_lng >= ? AND r.min_lng <= ? AND a.deleted = 0", ) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; let inside: Vec<&Asset> = candidates .iter() .filter(|a| match (a.lat, a.lng) { (Some(la), Some(lo)) => point_in_polygon(lo, la, &polygon), _ => false, }) .collect(); let split_key = |a: &Asset| -> String { match split_by.as_str() { "video" => a.video_name.clone(), _ => a .side .clone() .map(|s| s.to_uppercase()) .unwrap_or_else(|| "_".to_string()), } }; let pool: Vec<&Asset> = inside .iter() .filter(|a| a.link_locked.unwrap_or(0) != 1) .copied() .collect(); let (matched, leftover_a, leftover_b): (Vec<(i64, i64)>, usize, usize) = if split_by == "none" { // Pair nearest neighbours regardless of side/video. Useful when // video_name is missing or unreliable. let n = pool.len(); let mut p: Vec<(usize, usize, f64)> = Vec::with_capacity(n * n / 2); for i in 0..n { let (la, lo) = (pool[i].lat.unwrap(), pool[i].lng.unwrap()); for j in (i + 1)..n { let (lb, lob) = (pool[j].lat.unwrap(), pool[j].lng.unwrap()); let d = haversine_m((la, lo), (lb, lob)); if d <= max_distance_m { p.push((i, j, d)); } } } p.sort_by(|x, y| x.2.partial_cmp(&y.2).unwrap_or(std::cmp::Ordering::Equal)); let mut used = vec![false; n]; let mut m: Vec<(i64, i64)> = vec![]; for (i, j, _) in &p { if used[*i] || used[*j] { continue; } used[*i] = true; used[*j] = true; m.push((pool[*i].id, pool[*j].id)); } let leftover = used.iter().filter(|x| !**x).count(); (m, leftover, 0) } else { use std::collections::HashMap; let mut buckets: HashMap> = HashMap::new(); for a in &pool { buckets.entry(split_key(a)).or_default().push(a); } let mut groups: Vec<(String, Vec<&Asset>)> = buckets.into_iter().collect(); groups.sort_by(|a, b| b.1.len().cmp(&a.1.len())); if groups.len() < 2 { return Ok(AutoLinkResult { linked: 0, leftover_a: groups.first().map(|g| g.1.len()).unwrap_or(0), leftover_b: 0, }); } let group_a = groups[0].1.clone(); let group_b = groups[1].1.clone(); let mut p: Vec<(usize, usize, f64)> = Vec::with_capacity(group_a.len() * group_b.len()); for (i, a) in group_a.iter().enumerate() { let (la, lo) = (a.lat.unwrap(), a.lng.unwrap()); for (j, b) in group_b.iter().enumerate() { let (lb, lob) = (b.lat.unwrap(), b.lng.unwrap()); let d = haversine_m((la, lo), (lb, lob)); if d <= max_distance_m { p.push((i, j, d)); } } } p.sort_by(|x, y| x.2.partial_cmp(&y.2).unwrap_or(std::cmp::Ordering::Equal)); let mut used_a = vec![false; group_a.len()]; let mut used_b = vec![false; group_b.len()]; let mut m: Vec<(i64, i64)> = vec![]; for (i, j, _) in &p { if used_a[*i] || used_b[*j] { continue; } used_a[*i] = true; used_b[*j] = true; m.push((group_a[*i].id, group_b[*j].id)); } let la = used_a.iter().filter(|x| !**x).count(); let lb = used_b.iter().filter(|x| !**x).count(); (m, la, lb) }; let mut affected_ids: Vec = vec![]; for (a, b) in &matched { affected_ids.push(*a); affected_ids.push(*b); } // Pull in any *prior* partners of the matched ids — they get cleared by // the orphan UPDATE below, so they must appear in `before` for undo to // restore them. Without this, undo of an auto-link leaves the previous // partners with link_pair_id = NULL forever. if !affected_ids.is_empty() { let placeholders = vec!["?"; affected_ids.len()].join(","); let prior_sql = format!( "SELECT link_pair_id FROM assets WHERE id IN ({}) AND link_pair_id IS NOT NULL", placeholders ); let mut q = sqlx::query_as::<_, (Option,)>(&prior_sql); for id in &affected_ids { q = q.bind(id); } let prior_partners = q .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; for (pid_opt,) in prior_partners { if let Some(pid) = pid_opt { if !affected_ids.contains(&pid) { affected_ids.push(pid); } } } } let mut conn = state.pool.acquire().await.map_err(|e| e.to_string())?; let before = snapshot_link_state(&mut conn, &affected_ids).await?; drop(conn); let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; // Orphan any prior partners of the matched ids (only if they were unlocked). for id in &affected_ids { sqlx::query( "UPDATE assets SET link_pair_id = NULL, link_locked = 0 WHERE id IN (SELECT link_pair_id FROM assets WHERE id = ?) AND link_locked = 0", ) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } for (a, b) in &matched { sqlx::query("UPDATE assets SET link_pair_id = ?, link_locked = 0 WHERE id = ?") .bind(b) .bind(a) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; sqlx::query("UPDATE assets SET link_pair_id = ?, link_locked = 0 WHERE id = ?") .bind(a) .bind(b) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } // Read in-tx so snapshot reflects the link UPDATEs we just ran. let after = snapshot_link_state(&mut *tx, &affected_ids).await?; let ids_json = serde_json::to_string(&affected_ids).map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts) VALUES ('link', 'bulk', ?, ?, ?, ?, datetime('now'))", ) .bind(&ids_json) .bind(user_id()) .bind(serde_json::to_string(&before).map_err(|e| e.to_string())?) .bind(serde_json::to_string(&after).map_err(|e| e.to_string())?) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; Ok(AutoLinkResult { linked: matched.len(), leftover_a, leftover_b, }) } #[tauri::command] pub async fn set_link( a_id: i64, b_id: i64, state: State<'_, AppState>, ) -> Result<(), String> { if a_id == b_id { return Err("can't link an asset to itself".into()); } let prev: Vec<(i64, Option)> = sqlx::query_as("SELECT id, link_pair_id FROM assets WHERE id IN (?, ?)") .bind(a_id) .bind(b_id) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; let mut all_ids = vec![a_id, b_id]; for (_, p) in &prev { if let Some(pid) = p { if !all_ids.contains(pid) { all_ids.push(*pid); } } } let mut conn = state.pool.acquire().await.map_err(|e| e.to_string())?; let before = snapshot_link_state(&mut conn, &all_ids).await?; drop(conn); let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; for (_, p) in &prev { if let Some(pid) = p { sqlx::query("UPDATE assets SET link_pair_id = NULL, link_locked = 0 WHERE id = ?") .bind(pid) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } sqlx::query("UPDATE assets SET link_pair_id = ?, link_locked = 1 WHERE id = ?") .bind(b_id) .bind(a_id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; sqlx::query("UPDATE assets SET link_pair_id = ?, link_locked = 1 WHERE id = ?") .bind(a_id) .bind(b_id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; let after = snapshot_link_state(&mut *tx, &all_ids).await?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts) VALUES ('link', 'single', ?, ?, ?, ?, datetime('now'))", ) .bind(serde_json::to_string(&all_ids).map_err(|e| e.to_string())?) .bind(user_id()) .bind(serde_json::to_string(&before).map_err(|e| e.to_string())?) .bind(serde_json::to_string(&after).map_err(|e| e.to_string())?) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn clear_link(id: i64, state: State<'_, AppState>) -> Result<(), String> { let row: (Option,) = sqlx::query_as("SELECT link_pair_id FROM assets WHERE id = ?") .bind(id) .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; let mut all_ids = vec![id]; if let Some(p) = row.0 { all_ids.push(p); } let mut conn = state.pool.acquire().await.map_err(|e| e.to_string())?; let before = snapshot_link_state(&mut conn, &all_ids).await?; drop(conn); let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; for aid in &all_ids { sqlx::query("UPDATE assets SET link_pair_id = NULL, link_locked = 0 WHERE id = ?") .bind(aid) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } let after = snapshot_link_state(&mut *tx, &all_ids).await?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts) VALUES ('link', 'single', ?, ?, ?, ?, datetime('now'))", ) .bind(serde_json::to_string(&all_ids).map_err(|e| e.to_string())?) .bind(user_id()) .bind(serde_json::to_string(&before).map_err(|e| e.to_string())?) .bind(serde_json::to_string(&after).map_err(|e| e.to_string())?) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn change_assets_side( ids: Vec, side: String, state: State<'_, AppState>, ) -> Result { if ids.is_empty() { return Ok(0); } let normalized = match side.trim().to_uppercase().as_str() { "LEFT" | "LHS" | "L" => "Left", "RIGHT" | "RHS" | "R" => "Right", _ => return Err(format!("invalid side: {}", side)), }; // Chunked at 500 to stay under SQLite's 999 bind-param ceiling. let mut before_map = serde_json::Map::new(); for chunk in ids.chunks(500) { let ph = vec!["?"; chunk.len()].join(","); let before_sql = format!( "SELECT id, side FROM assets WHERE id IN ({})", ph ); let mut bq = sqlx::query_as::<_, (i64, Option)>(&before_sql); for id in chunk { bq = bq.bind(id); } let prior = bq.fetch_all(&state.pool).await.map_err(|e| e.to_string())?; for (id, side_v) in prior { before_map.insert(id.to_string(), serde_json::json!({"side": side_v})); } } let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut total_affected: u64 = 0; for chunk in ids.chunks(500) { let ph = vec!["?"; chunk.len()].join(","); let upd_sql = format!( "UPDATE assets SET side = ?, modified = 1, modified_by = ?, \ modified_at = datetime('now') WHERE id IN ({})", ph ); let mut q = sqlx::query(&upd_sql).bind(normalized).bind(user_id()); for id in chunk { q = q.bind(id); } total_affected += q .execute(&mut *tx) .await .map_err(|e| e.to_string())? .rows_affected(); } // Mimic the previous return-by-rows-affected shape. let res = total_affected; let after = serde_json::json!({"side": normalized}); let ids_json = serde_json::to_string(&ids).map_err(|e| e.to_string())?; let before_json = serde_json::to_string(&before_map).map_err(|e| e.to_string())?; let after_json = serde_json::to_string(&after).map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts) VALUES ('change_side', 'bulk', ?, ?, ?, ?, datetime('now'))", ) .bind(&ids_json) .bind(user_id()) .bind(&before_json) .bind(&after_json) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; Ok(res as usize) } #[tauri::command] pub async fn rename_assets( ids: Vec, asset_name: String, state: State<'_, AppState>, ) -> Result { if ids.is_empty() { return Ok(0); } let trimmed = asset_name.trim().to_string(); if trimmed.is_empty() { return Err("asset_name is empty".into()); } // Chunked at 500 to stay under SQLite's 999 bind-param ceiling. let mut before_map = serde_json::Map::new(); for chunk in ids.chunks(500) { let ph = vec!["?"; chunk.len()].join(","); let before_sql = format!( "SELECT id, asset_name FROM assets WHERE id IN ({})", ph ); let mut bq = sqlx::query_as::<_, (i64, String)>(&before_sql); for id in chunk { bq = bq.bind(id); } let prior = bq .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; for (id, name) in prior { before_map.insert(id.to_string(), serde_json::json!({"asset_name": name})); } } let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut total_affected: u64 = 0; for chunk in ids.chunks(500) { let ph = vec!["?"; chunk.len()].join(","); let upd_sql = format!( "UPDATE assets SET asset_name = ?, modified = 1, modified_by = ?, \ modified_at = datetime('now') WHERE id IN ({})", ph ); let mut q = sqlx::query(&upd_sql).bind(&trimmed).bind(user_id()); for id in chunk { q = q.bind(id); } total_affected += q .execute(&mut *tx) .await .map_err(|e| e.to_string())? .rows_affected(); } let res = total_affected; let after = serde_json::json!({"asset_name": trimmed}); let ids_json = serde_json::to_string(&ids).map_err(|e| e.to_string())?; let before_json = serde_json::to_string(&before_map).map_err(|e| e.to_string())?; let after_json = serde_json::to_string(&after).map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts) VALUES ('rename', 'bulk', ?, ?, ?, ?, datetime('now'))", ) .bind(&ids_json) .bind(user_id()) .bind(&before_json) .bind(&after_json) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; Ok(res as usize) } #[derive(Debug, Serialize)] pub struct DuplicateMember { pub id: i64, pub row_id: String, pub asset_name: String, pub video_name: String, pub side: Option, pub lat: Option, pub lng: Option, pub asset_type: String, pub start_lat: Option, pub start_lng: Option, pub end_lat: Option, pub end_lng: Option, } #[derive(Debug, Serialize)] pub struct DuplicateCluster { pub id: usize, pub asset_name: String, pub members: Vec, } #[tauri::command] pub async fn find_duplicate_clusters( eps_m: f64, min_lat: Option, max_lat: Option, min_lng: Option, max_lng: Option, cross_video_only: Option, state: State<'_, AppState>, ) -> Result, String> { let cross_only = cross_video_only.unwrap_or(false); if eps_m <= 0.0 { return Err("eps_m must be > 0".into()); } let assets: Vec = if let (Some(a), Some(b), Some(c), Some(d)) = (min_lat, max_lat, min_lng, max_lng) { sqlx::query_as( "SELECT a.id, a.row_id, a.asset_type, a.asset_name, a.video_name, a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng, a.image_path, a.image_path1, a.image_path2, a.image_path3, a.deleted, a.modified, a.merged_into, a.in_scope, a.side, a.link_pair_id, a.link_locked FROM assets a JOIN assets_rtree r ON r.id = a.id WHERE r.max_lat >= ? AND r.min_lat <= ? AND r.max_lng >= ? AND r.min_lng <= ? AND a.deleted = 0", ) .bind(a) .bind(b) .bind(c) .bind(d) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())? } else { sqlx::query_as( "SELECT id, row_id, asset_type, asset_name, video_name, lat, lng, start_lat, start_lng, end_lat, end_lng, image_path, image_path1, image_path2, image_path3, deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked FROM assets WHERE deleted = 0", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())? }; use std::collections::HashMap; let mut buckets: HashMap<(String, String), Vec<&Asset>> = HashMap::new(); for a in &assets { if a.lat.is_none() || a.lng.is_none() { continue; } let side = a.side.clone().unwrap_or_default(); buckets .entry((a.asset_name.clone(), side)) .or_default() .push(a); } fn find(parent: &mut [usize], x: usize) -> usize { let mut r = x; while parent[r] != r { r = parent[r]; } let mut cur = x; while parent[cur] != r { let nxt = parent[cur]; parent[cur] = r; cur = nxt; } r } fn union(parent: &mut [usize], a: usize, b: usize) { let ra = find(parent, a); let rb = find(parent, b); if ra != rb { parent[ra] = rb; } } let mut clusters: Vec = vec![]; let mut next_cluster_id = 0usize; for ((name, _side), group) in buckets { if group.len() < 2 { continue; } let n = group.len(); let mut parent: Vec = (0..n).collect(); for i in 0..n { for j in (i + 1)..n { let a = group[i]; let b = group[j]; if cross_only { let av = a.video_name.trim(); let bv = b.video_name.trim(); // Empty/missing video names never count as "same" — that // would silently block dup detection on assets where the // labeler forgot to set the video. if !av.is_empty() && !bv.is_empty() && av == bv { continue; } } let pa = Point::new(a.lng.unwrap(), a.lat.unwrap()); let pb = Point::new(b.lng.unwrap(), b.lat.unwrap()); if Geodesic.distance(pa, pb) > eps_m { continue; } // Range × range: also require best-paired endpoints OR best // endpoint <-> any-vertex distance to be within 2×eps. This is // looser than before so ranges that overlap but end at slightly // different points still cluster. Mismatched-direction pairs // still pass via the min(same,opp) test. if a.asset_type == "range" && b.asset_type == "range" { let endpoints_ok = (|| { let asa = Point::new(a.start_lng?, a.start_lat?); let ase = Point::new(a.end_lng?, a.end_lat?); let bsa = Point::new(b.start_lng?, b.start_lat?); let bse = Point::new(b.end_lng?, b.end_lat?); let same = Geodesic .distance(asa, bsa) .max(Geodesic.distance(ase, bse)); let opp = Geodesic .distance(asa, bse) .max(Geodesic.distance(ase, bsa)); Some(same.min(opp) <= eps_m * 2.0) })() .unwrap_or(true); if !endpoints_ok { continue; } } union(&mut parent, i, j); } } let mut by_root: HashMap> = HashMap::new(); for i in 0..n { let r = find(&mut parent, i); by_root.entry(r).or_default().push(i); } for (_root, members) in by_root { if members.len() < 2 { continue; } if cross_only { let mut videos = std::collections::HashSet::new(); for &i in &members { videos.insert(group[i].video_name.clone()); } if videos.len() < 2 { continue; } } let mut out_members: Vec = vec![]; for &i in &members { let m = group[i]; out_members.push(DuplicateMember { id: m.id, row_id: m.row_id.clone(), asset_name: m.asset_name.clone(), video_name: m.video_name.clone(), side: m.side.clone(), lat: m.lat, lng: m.lng, asset_type: m.asset_type.clone(), start_lat: m.start_lat, start_lng: m.start_lng, end_lat: m.end_lat, end_lng: m.end_lng, }); } clusters.push(DuplicateCluster { id: next_cluster_id, asset_name: name.clone(), members: out_members, }); next_cluster_id += 1; } } clusters.sort_by(|a, b| b.members.len().cmp(&a.members.len())); Ok(clusters) } #[tauri::command] pub async fn list_video_names( state: State<'_, AppState>, ) -> Result, String> { let rows: Vec<(String,)> = sqlx::query_as( "SELECT DISTINCT video_name FROM assets WHERE deleted = 0 ORDER BY video_name", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; Ok(rows.into_iter().map(|(n,)| n).collect()) } #[derive(Debug, Serialize)] pub struct VideoEntry { pub video_name: String, pub video_number: i64, pub completed: bool, pub completed_at: Option, pub completed_by: Option, } /// Sort key for stable numbering: parse the first `YYYY_MMDD_HHMMSS` pattern /// out of the video name (timestamps captured by the on-vehicle recorder). /// Names without a timestamp sort after timestamped ones, lexically. fn video_sort_key(name: &str) -> String { let bytes = name.as_bytes(); // Window-scan for the pattern: 4 digits, _, 4 digits, _, 6 digits. let mut i = 0; while i + 16 <= bytes.len() { let w = &bytes[i..i + 16]; let is_digits = |b: &[u8]| b.iter().all(|c| c.is_ascii_digit()); if w[4] == b'_' && w[9] == b'_' && is_digits(&w[0..4]) && is_digits(&w[5..9]) && is_digits(&w[10..16]) { // 14-char numeric key sorts chronologically. let mut key = String::with_capacity(14 + 1 + name.len()); key.push_str(std::str::from_utf8(&w[0..4]).unwrap_or("")); key.push_str(std::str::from_utf8(&w[5..9]).unwrap_or("")); key.push_str(std::str::from_utf8(&w[10..16]).unwrap_or("")); key.push('_'); key.push_str(name); return key; } i += 1; } // No timestamp: prefix with sentinel that sorts after any real timestamp. let mut key = String::with_capacity(15 + name.len()); key.push_str("99999999999999_"); key.push_str(name); key } /// Return every distinct video name (from assets + video_metadata + the /// status table) annotated with a deterministic 1-based number and its /// completion state. Sort: parsed timestamp asc, then video_name asc. The /// numbering is recomputed on every call, so adding a new video shifts later /// numbers consistently for every client sharing the DB. #[tauri::command] pub async fn list_videos( state: State<'_, AppState>, ) -> Result, String> { let names: Vec<(String,)> = sqlx::query_as( "SELECT DISTINCT video_name FROM ( SELECT video_name FROM assets WHERE deleted = 0 UNION SELECT video_name FROM video_metadata UNION SELECT video_name FROM video_status ) WHERE TRIM(COALESCE(video_name, '')) <> ''", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; let mut all: Vec = names.into_iter().map(|(n,)| n).collect(); all.sort_by(|a, b| video_sort_key(a).cmp(&video_sort_key(b))); let status_rows: Vec<(String, i64, Option, Option)> = sqlx::query_as( "SELECT video_name, completed, completed_at, completed_by FROM video_status", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; let mut status: std::collections::HashMap< String, (i64, Option, Option), > = std::collections::HashMap::new(); for (n, c, at, by) in status_rows { status.insert(n, (c, at, by)); } let mut out: Vec = Vec::with_capacity(all.len()); for (idx, n) in all.into_iter().enumerate() { let (completed, completed_at, completed_by) = status.remove(&n).unwrap_or((0, None, None)); out.push(VideoEntry { video_number: (idx + 1) as i64, video_name: n, completed: completed != 0, completed_at, completed_by, }); } Ok(out) } #[tauri::command] pub async fn set_video_completed( video_name: String, completed: bool, state: State<'_, AppState>, ) -> Result<(), String> { let user = user_id(); sqlx::query( "INSERT INTO video_status(video_name, completed, completed_at, completed_by) VALUES (?, ?, CASE WHEN ? THEN datetime('now') ELSE NULL END, ?) ON CONFLICT(video_name) DO UPDATE SET completed = excluded.completed, completed_at = CASE WHEN excluded.completed = 1 THEN datetime('now') ELSE NULL END, completed_by = CASE WHEN excluded.completed = 1 THEN excluded.completed_by ELSE NULL END", ) .bind(&video_name) .bind(if completed { 1i64 } else { 0 }) .bind(if completed { 1i64 } else { 0 }) .bind(&user) .execute(&state.pool) .await .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn restore_assets( ids: Vec, state: State<'_, AppState>, ) -> Result { if ids.is_empty() { return Ok(0); } let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut total_affected: u64 = 0; for chunk in ids.chunks(500) { let ph = vec!["?"; chunk.len()].join(","); let upd_sql = format!( "UPDATE assets SET deleted = 0, modified = 1, modified_by = ?, \ modified_at = datetime('now') WHERE id IN ({}) AND deleted = 1", ph ); let mut q = sqlx::query(&upd_sql).bind(user_id()); for id in chunk { q = q.bind(id); } total_affected += q .execute(&mut *tx) .await .map_err(|e| e.to_string())? .rows_affected(); } let ids_json = serde_json::to_string(&ids).map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, ts) VALUES ('restore', 'bulk', ?, ?, datetime('now'))", ) .bind(&ids_json) .bind(user_id()) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; Ok(total_affected as usize) } #[tauri::command] pub async fn list_asset_names( state: State<'_, AppState>, ) -> Result, String> { let rows: Vec<(String,)> = sqlx::query_as( "SELECT DISTINCT asset_name FROM assets WHERE deleted = 0 ORDER BY asset_name", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; Ok(rows.into_iter().map(|(n,)| n).collect()) } #[tauri::command] pub async fn get_osm_roads(state: State<'_, AppState>) -> Result, String> { let rows: Vec<(i64, Option, String, i64, i64)> = sqlx::query_as( "SELECT id, name, geojson, COALESCE(snap_ignored, 0), COALESCE(modified, 0) FROM roads", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; Ok(rows .into_iter() .map(|(id, name, geojson, si, m)| RoadOut { id, name, geojson, snap_ignored: si != 0, modified: m != 0, }) .collect()) } #[tauri::command] pub async fn read_metadata_polyline(path: String) -> Result, String> { let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?; let v: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; let track = v .get("track") .and_then(|t| t.as_array()) .ok_or_else(|| "missing 'track' array".to_string())?; let mut out = vec![]; for pt in track { let arr = match pt.as_array() { Some(a) if a.len() >= 2 => a, _ => continue, }; // metadata_polyline.json stores [lat, lng]; convert to [lng, lat] for deck.gl let lat = arr[0].as_f64().unwrap_or(f64::NAN); let lng = arr[1].as_f64().unwrap_or(f64::NAN); if lat.is_finite() && lng.is_finite() { out.push([lng, lat]); } } Ok(out) } #[tauri::command] pub async fn import_osm_geojson( path: String, highway_classes: Option>, state: State<'_, AppState>, ) -> Result { let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?; let fc: GeoJsonFeatureCollection = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; let class_filter: Option> = highway_classes.map(|v| v.into_iter().collect()); let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; sqlx::query("DELETE FROM roads") .execute(&mut *tx) .await .map_err(|e| e.to_string())?; let mut inserted = 0usize; for f in fc.features { let highway = f .properties .get("highway") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); if let Some(set) = &class_filter { if !highway.is_empty() && !set.contains(&highway) { continue; } } let geom_type = f .geometry .get("type") .and_then(|v| v.as_str()) .unwrap_or(""); if geom_type != "LineString" { continue; } let coords_v = f .geometry .get("coordinates") .and_then(|v| v.as_array()) .ok_or_else(|| "missing coordinates".to_string())?; let mut min_lat = f64::INFINITY; let mut max_lat = f64::NEG_INFINITY; let mut min_lng = f64::INFINITY; let mut max_lng = f64::NEG_INFINITY; let mut valid = false; for c in coords_v { let arr = match c.as_array() { Some(a) if a.len() >= 2 => a, _ => continue, }; let lng = arr[0].as_f64().unwrap_or(f64::NAN); let lat = arr[1].as_f64().unwrap_or(f64::NAN); if !lng.is_finite() || !lat.is_finite() { continue; } if lat < min_lat { min_lat = lat; } if lat > max_lat { max_lat = lat; } if lng < min_lng { min_lng = lng; } if lng > max_lng { max_lng = lng; } valid = true; } if !valid { continue; } let name = f .properties .get("name") .and_then(|v| v.as_str()) .or_else(|| f.properties.get("ref").and_then(|v| v.as_str())) .map(|s| s.to_string()); let oneway: i64 = match f.properties.get("oneway") { Some(serde_json::Value::Number(n)) => n.as_i64().unwrap_or(0), Some(serde_json::Value::String(s)) => match s.as_str() { "yes" | "true" | "1" => 1, "-1" | "reverse" => -1, _ => 0, }, Some(serde_json::Value::Bool(true)) => 1, _ => 0, }; let geojson_str = f.geometry.to_string(); sqlx::query( "INSERT INTO roads(name, geojson, min_lat, max_lat, min_lng, max_lng, oneway, highway) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&name) .bind(&geojson_str) .bind(min_lat) .bind(max_lat) .bind(min_lng) .bind(max_lng) .bind(oneway) .bind(&highway) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; inserted += 1; } tx.commit().await.map_err(|e| e.to_string())?; Ok(inserted) } #[derive(Debug, Serialize)] pub struct MergeRoadsResult { pub names_processed: usize, pub ways_consumed: usize, pub merged_into: usize, pub skipped_branched: usize, } /// Merge OSM ways that share a `name` and connect end-to-end into a single /// LineString. Endpoints within 1 m are treated as the same node. /// Branched components (any node with > 2 incident ways) are skipped to keep /// merged geometry single-stranded; user can still edit those individually. #[tauri::command] pub async fn merge_roads_by_name( state: State<'_, AppState>, ) -> Result { use std::collections::{HashMap, HashSet}; // Pull every named road. Empty / NULL names are skipped (we can't group them). let rows: Vec<(i64, String, String, i64, Option, i64)> = sqlx::query_as( "SELECT id, geojson, COALESCE(name, ''), COALESCE(oneway, 0), highway, COALESCE(snap_ignored, 0) FROM roads WHERE name IS NOT NULL AND TRIM(name) <> ''", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; // Bucket by name. Carry snap_ignored alongside oneway/highway so the // merged road can keep the dominant value (otherwise the merged row // silently re-enables snapping for ways the user had muted). let mut by_name: HashMap< String, Vec<(i64, LineString, i64, Option, i64)>, > = HashMap::new(); for (id, gj, name, ow, hw, si) in rows { let line = match parse_linestring(&gj) { Some(l) => l, None => continue, }; by_name .entry(name) .or_default() .push((id, line, ow, hw, si)); } // Endpoint keying: round to 5 decimals (~1 m). Two ways are adjacent if // any endpoint key matches. fn key(c: &Coord) -> (i64, i64) { ((c.x * 1e5).round() as i64, (c.y * 1e5).round() as i64) } let mut names_processed = 0usize; let mut ways_consumed = 0usize; let mut merged_into = 0usize; let mut skipped_branched = 0usize; let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; for (name, ways) in by_name { if ways.len() < 2 { continue; } names_processed += 1; let n = ways.len(); // Map each endpoint key to the list of way indices touching it. let mut node_to_ways: HashMap<(i64, i64), Vec> = HashMap::new(); for (i, (_, line, _, _, _)) in ways.iter().enumerate() { let s = line.0.first().unwrap(); let e = line.0.last().unwrap(); node_to_ways.entry(key(s)).or_default().push(i); node_to_ways.entry(key(e)).or_default().push(i); } // Find connected components via BFS on ways. let mut visited = vec![false; n]; for start in 0..n { if visited[start] { continue; } let mut comp: Vec = vec![]; let mut stack = vec![start]; while let Some(u) = stack.pop() { if visited[u] { continue; } visited[u] = true; comp.push(u); let line = &ways[u].1; for k in [key(line.0.first().unwrap()), key(line.0.last().unwrap())] { if let Some(peers) = node_to_ways.get(&k) { for &v in peers { if !visited[v] { stack.push(v); } } } } } if comp.len() < 2 { continue; } // Branched check: count incident ways per endpoint, restricted to comp. let comp_set: HashSet = comp.iter().copied().collect(); let mut deg: HashMap<(i64, i64), Vec> = HashMap::new(); for &i in &comp { let line = &ways[i].1; deg.entry(key(line.0.first().unwrap())).or_default().push(i); deg.entry(key(line.0.last().unwrap())).or_default().push(i); } let max_deg = deg.values().map(|v| v.len()).max().unwrap_or(0); if max_deg > 2 { skipped_branched += 1; continue; } // Find walk start: a node with degree 1 (chain endpoint) or any node (cycle). let start_node = deg .iter() .find(|(_, v)| v.len() == 1) .map(|(k, _)| *k) .unwrap_or_else(|| *deg.keys().next().unwrap()); // Walk: from start_node, pick its way, follow to the other end, repeat. let mut ordered_segments: Vec>> = vec![]; let mut current_node = start_node; let mut used: HashSet = HashSet::new(); while used.len() < comp.len() { let nexts = deg.get(¤t_node).cloned().unwrap_or_default(); let pick = nexts .into_iter() .find(|i| !used.contains(i) && comp_set.contains(i)); let way_idx = match pick { Some(x) => x, None => break, }; used.insert(way_idx); let line = &ways[way_idx].1; let s_key = key(line.0.first().unwrap()); let e_key = key(line.0.last().unwrap()); let coords: Vec> = if s_key == current_node { line.0.clone() } else { line.0.iter().rev().copied().collect() }; let next_node = if s_key == current_node { e_key } else { s_key }; ordered_segments.push(coords); current_node = next_node; } if used.len() != comp.len() { // Couldn't walk the whole thing — bail rather than corrupt geometry. skipped_branched += 1; continue; } // Concatenate; drop duplicate junction vertex between consecutive segments. let mut merged: Vec> = vec![]; for (i, seg) in ordered_segments.into_iter().enumerate() { let start = if i == 0 { 0 } else { 1 }; merged.extend(seg.into_iter().skip(start)); } if merged.len() < 2 { continue; } // Dominant oneway (most common among comp members). let mut ow_count: HashMap = HashMap::new(); for &i in &comp { *ow_count.entry(ways[i].2).or_default() += 1; } let dom_ow = ow_count .into_iter() .max_by_key(|(_, c)| *c) .map(|(k, _)| k) .unwrap_or(0); // Pick a highway value (use any present). let dom_hw = comp .iter() .find_map(|&i| ways[i].3.clone()) .unwrap_or_default(); // Preserve snap_ignored: if ANY member of the cluster was muted // for snap, the merged road inherits that — better to err on the // side of "keep the user's mute" than to silently re-enable. let any_ignored = comp.iter().any(|&i| ways[i].4 != 0); // Compute bbox. let mut mn_lat = f64::INFINITY; let mut mx_lat = f64::NEG_INFINITY; let mut mn_lng = f64::INFINITY; let mut mx_lng = f64::NEG_INFINITY; for c in &merged { if c.y < mn_lat { mn_lat = c.y; } if c.y > mx_lat { mx_lat = c.y; } if c.x < mn_lng { mn_lng = c.x; } if c.x > mx_lng { mx_lng = c.x; } } // Delete the source rows. for &i in &comp { sqlx::query("DELETE FROM roads WHERE id = ?") .bind(ways[i].0) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } // Insert merged. let coords_array: Vec<[f64; 2]> = merged.iter().map(|c| [c.x, c.y]).collect(); let geom = serde_json::json!({ "type": "LineString", "coordinates": coords_array, }); sqlx::query( "INSERT INTO roads(name, geojson, min_lat, max_lat, min_lng, max_lng, oneway, highway, snap_ignored, modified) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)", ) .bind(&name) .bind(geom.to_string()) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(dom_ow) .bind(&dom_hw) .bind(if any_ignored { 1i64 } else { 0i64 }) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; ways_consumed += comp.len(); merged_into += 1; } } tx.commit().await.map_err(|e| e.to_string())?; Ok(MergeRoadsResult { names_processed, ways_consumed, merged_into, skipped_branched, }) } /// Merge an explicit set of road ids. Connected clusters become single /// LineStrings; non-touching clusters stay as independent merged roads (or /// untouched if a cluster has < 2 ways or is branched). Endpoints within /// `snap_meters` (default 8 m) of each other are clustered so OSM ways with /// near-but-not-touching endpoints can still merge — the resulting geometry /// snaps both ends to the cluster centroid so there's no visible gap. #[tauri::command] pub async fn merge_roads_by_ids( ids: Vec, snap_meters: Option, state: State<'_, AppState>, ) -> Result { use std::collections::{HashMap, HashSet}; if ids.is_empty() { return Ok(MergeRoadsResult { names_processed: 0, ways_consumed: 0, merged_into: 0, skipped_branched: 0, }); } let snap_m = snap_meters.unwrap_or(8.0).max(0.0); // Load the requested rows. Chunk to stay under the 999-bind ceiling. // Tuple shape: (id, line, oneway, highway, name, snap_ignored). let mut ways: Vec<( i64, LineString, i64, Option, Option, i64, )> = Vec::with_capacity(ids.len()); for chunk in ids.chunks(500) { let placeholders = vec!["?"; chunk.len()].join(","); let sql = format!( "SELECT id, geojson, COALESCE(oneway, 0), highway, name, COALESCE(snap_ignored, 0) FROM roads WHERE id IN ({})", placeholders ); let mut q = sqlx::query_as::< _, (i64, String, i64, Option, Option, i64), >(&sql); for &i in chunk { q = q.bind(i); } let rows = q.fetch_all(&state.pool).await.map_err(|e| e.to_string())?; for (id, gj, ow, hw, nm, si) in rows { if let Some(line) = parse_linestring(&gj) { ways.push((id, line, ow, hw, nm, si)); } } } if ways.len() < 2 { return Ok(MergeRoadsResult { names_processed: 0, ways_consumed: 0, merged_into: 0, skipped_branched: 0, }); } let n = ways.len(); // Endpoint clustering: greedy spatial-grid pass that joins endpoints // within snap_m. Each endpoint either lands in an existing cluster (≤ snap_m // from its representative) or starts a new one. After clustering, every // way's first/last vertex is rewritten to its cluster's centroid so the // walk produces continuous geometry without visible gaps. let cell_deg = if snap_m > 0.0 { (snap_m / 111_320.0).max(1e-7) } else { 1e-7 }; fn dist_m(a: (f64, f64), b: (f64, f64)) -> f64 { let lat0 = ((a.1 + b.1) * 0.5) * std::f64::consts::PI / 180.0; let dx = (b.0 - a.0) * 111_320.0 * lat0.cos(); let dy = (b.1 - a.1) * 111_320.0; (dx * dx + dy * dy).sqrt() } // (sum_x, sum_y, count) per cluster — running centroid so successive // endpoints don't drift the representative away from earlier members. let mut cluster_sum: Vec<(f64, f64, usize)> = vec![]; let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); let mut endpoint_cluster: Vec = Vec::with_capacity(n * 2); for w in 0..n { for end in 0..2 { let coord = if end == 0 { *ways[w].1 .0.first().unwrap() } else { *ways[w].1 .0.last().unwrap() }; let p = (coord.x, coord.y); let gx = (p.0 / cell_deg).floor() as i64; let gy = (p.1 / cell_deg).floor() as i64; let mut found: Option = None; 'search: for dy in -1..=1 { for dx in -1..=1 { if let Some(cell_clusters) = grid.get(&(gx + dx, gy + dy)) { for &cid in cell_clusters { let (sx, sy, c) = cluster_sum[cid]; let centroid = (sx / c as f64, sy / c as f64); if dist_m(p, centroid) <= snap_m { found = Some(cid); break 'search; } } } } } let cid = if let Some(c) = found { let (sx, sy, k) = cluster_sum[c]; cluster_sum[c] = (sx + p.0, sy + p.1, k + 1); c } else { let cid = cluster_sum.len(); cluster_sum.push((p.0, p.1, 1)); grid.entry((gx, gy)).or_default().push(cid); cid }; endpoint_cluster.push(cid); } } // Rewrite each way's first/last vertex to its cluster centroid so the // resulting geometry is continuous after the walk. for w in 0..n { let s_cid = endpoint_cluster[w * 2]; let e_cid = endpoint_cluster[w * 2 + 1]; let (ssx, ssy, sc) = cluster_sum[s_cid]; let (esx, esy, ec) = cluster_sum[e_cid]; let s_rep = (ssx / sc as f64, ssy / sc as f64); let e_rep = (esx / ec as f64, esy / ec as f64); if let Some(first) = ways[w].1 .0.first_mut() { first.x = s_rep.0; first.y = s_rep.1; } if let Some(last) = ways[w].1 .0.last_mut() { last.x = e_rep.0; last.y = e_rep.1; } } // After clustering every endpoint coord is exact, so the rounded `key()` // collapses both ends of a connection to the same map key. Keeps the rest // of the walk identical to merge_roads_by_name. fn key(c: &Coord) -> (i64, i64) { ((c.x * 1e5).round() as i64, (c.y * 1e5).round() as i64) } let mut node_to_ways: HashMap<(i64, i64), Vec> = HashMap::new(); for (i, (_, line, _, _, _, _)) in ways.iter().enumerate() { let s = line.0.first().unwrap(); let e = line.0.last().unwrap(); node_to_ways.entry(key(s)).or_default().push(i); node_to_ways.entry(key(e)).or_default().push(i); } let mut ways_consumed = 0usize; let mut merged_into = 0usize; let mut skipped_branched = 0usize; let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut visited = vec![false; n]; for start in 0..n { if visited[start] { continue; } let mut comp: Vec = vec![]; let mut stack = vec![start]; while let Some(u) = stack.pop() { if visited[u] { continue; } visited[u] = true; comp.push(u); let line = &ways[u].1; for k in [ key(line.0.first().unwrap()), key(line.0.last().unwrap()), ] { if let Some(peers) = node_to_ways.get(&k) { for &v in peers { if !visited[v] { stack.push(v); } } } } } if comp.len() < 2 { continue; } let comp_set: HashSet = comp.iter().copied().collect(); let mut deg: HashMap<(i64, i64), Vec> = HashMap::new(); for &i in &comp { let line = &ways[i].1; deg.entry(key(line.0.first().unwrap())).or_default().push(i); deg.entry(key(line.0.last().unwrap())).or_default().push(i); } let max_deg = deg.values().map(|v| v.len()).max().unwrap_or(0); if max_deg > 2 { skipped_branched += 1; continue; } let start_node = deg .iter() .find(|(_, v)| v.len() == 1) .map(|(k, _)| *k) .unwrap_or_else(|| *deg.keys().next().unwrap()); let mut ordered_segments: Vec>> = vec![]; let mut current_node = start_node; let mut used: HashSet = HashSet::new(); while used.len() < comp.len() { let nexts = deg.get(¤t_node).cloned().unwrap_or_default(); let pick = nexts .into_iter() .find(|i| !used.contains(i) && comp_set.contains(i)); let way_idx = match pick { Some(x) => x, None => break, }; used.insert(way_idx); let line = &ways[way_idx].1; let s_key = key(line.0.first().unwrap()); let e_key = key(line.0.last().unwrap()); let coords: Vec> = if s_key == current_node { line.0.clone() } else { line.0.iter().rev().copied().collect() }; let next_node = if s_key == current_node { e_key } else { s_key }; ordered_segments.push(coords); current_node = next_node; } if used.len() != comp.len() { skipped_branched += 1; continue; } let mut merged: Vec> = vec![]; for (i, seg) in ordered_segments.into_iter().enumerate() { let start = if i == 0 { 0 } else { 1 }; merged.extend(seg.into_iter().skip(start)); } if merged.len() < 2 { continue; } // Pick the most common non-empty name across the cluster; fall back // to "" if none has one. let mut name_count: HashMap = HashMap::new(); for &i in &comp { if let Some(n) = ways[i].4.as_ref() { let t = n.trim(); if !t.is_empty() { *name_count.entry(t.to_string()).or_default() += 1; } } } let dom_name = name_count .into_iter() .max_by_key(|(_, c)| *c) .map(|(k, _)| k) .unwrap_or_default(); let mut ow_count: HashMap = HashMap::new(); for &i in &comp { *ow_count.entry(ways[i].2).or_default() += 1; } let dom_ow = ow_count .into_iter() .max_by_key(|(_, c)| *c) .map(|(k, _)| k) .unwrap_or(0); let dom_hw = comp .iter() .find_map(|&i| ways[i].3.clone()) .unwrap_or_default(); // Preserve snap_ignored: if any cluster member was muted, the merged // road inherits the mute. Avoids silently re-enabling snap on roads // the user had explicitly excluded. let any_ignored = comp.iter().any(|&i| ways[i].5 != 0); let mut mn_lat = f64::INFINITY; let mut mx_lat = f64::NEG_INFINITY; let mut mn_lng = f64::INFINITY; let mut mx_lng = f64::NEG_INFINITY; for c in &merged { if c.y < mn_lat { mn_lat = c.y; } if c.y > mx_lat { mx_lat = c.y; } if c.x < mn_lng { mn_lng = c.x; } if c.x > mx_lng { mx_lng = c.x; } } for &i in &comp { sqlx::query("DELETE FROM roads WHERE id = ?") .bind(ways[i].0) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } let coords_array: Vec<[f64; 2]> = merged.iter().map(|c| [c.x, c.y]).collect(); let geom = serde_json::json!({ "type": "LineString", "coordinates": coords_array, }); sqlx::query( "INSERT INTO roads(name, geojson, min_lat, max_lat, min_lng, max_lng, oneway, highway, snap_ignored, modified) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)", ) .bind(if dom_name.is_empty() { None } else { Some(dom_name) }) .bind(geom.to_string()) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(dom_ow) .bind(if dom_hw.is_empty() { None } else { Some(dom_hw) }) .bind(if any_ignored { 1i64 } else { 0i64 }) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; ways_consumed += comp.len(); merged_into += 1; } tx.commit().await.map_err(|e| e.to_string())?; Ok(MergeRoadsResult { names_processed: 0, ways_consumed, merged_into, skipped_branched, }) } #[tauri::command] pub async fn export_osm_roads( path: String, state: State<'_, AppState>, ) -> Result { let rows: Vec<(i64, Option, String, i64, Option)> = sqlx::query_as( "SELECT id, name, geojson, COALESCE(oneway, 0), highway FROM roads", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; let mut features: Vec = Vec::with_capacity(rows.len()); for (id, name, gj, ow, hw) in &rows { let geom: serde_json::Value = match serde_json::from_str(gj) { Ok(v) => v, Err(_) => continue, }; features.push(serde_json::json!({ "type": "Feature", "geometry": geom, "properties": { "id": id, "name": name, "oneway": ow, "highway": hw, }, })); } let fc = serde_json::json!({ "type": "FeatureCollection", "features": features, }); tokio::fs::write(&path, serde_json::to_string_pretty(&fc).map_err(|e| e.to_string())?) .await .map_err(|e| e.to_string())?; Ok(features.len()) } #[derive(Debug, Serialize)] pub struct BboxOut { pub min_lat: f64, pub max_lat: f64, pub min_lng: f64, pub max_lng: f64, } // Same Overpass road-class set used by the bbox-based generator. Kept as a // constant so both code paths stay in sync. const OSM_HIGHWAY_CLASSES: &str = "motorway|trunk|primary|secondary|tertiary|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link|residential|unclassified|service"; #[derive(Debug, Serialize)] pub struct GenerateOsmResult { pub ways_kept: usize, pub source: String, pub points_used: usize, pub videos_used: usize, } #[tauri::command] pub async fn generate_osm_near_assets( min_lat: f64, min_lng: f64, max_lat: f64, max_lng: f64, keep_threshold_m: Option, path: String, overpass_url: Option, state: State<'_, AppState>, ) -> Result { // New flow: Overpass query is the visible bbox (gives every way in view, // including highways that earlier around: + min_votes=2 dropped). The // returned ways are then filtered against a unified reference set built // from per-video metadata polyline points + live asset coords. A way is // kept if any of its segments comes within `keep_threshold_m` of any // reference point. This covers highways with sparse assets via the // metadata polyline (the GPS track always touches the highway it drove) // and urban side streets via the dense asset cloud. let threshold_m = keep_threshold_m.unwrap_or(50.0).max(1.0); if !(min_lat < max_lat) || !(min_lng < max_lng) { return Err("invalid bbox: min must be < max for both lat and lng".into()); } // --- Build the reference-point set (lng, lat). --- // Pull asset centres for the post-filter votes. let asset_coords: Vec<(f64, f64)> = sqlx::query_as::<_, (f64, f64)>( "SELECT lng, lat FROM assets WHERE deleted = 0 AND lat IS NOT NULL AND lng IS NOT NULL", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; // Pull every metadata polyline point (DB-persisted, stored as // [[lng, lat], ...] to match deck.gl convention). let metadata_rows: Vec<(String,)> = sqlx::query_as( "SELECT track_json FROM video_metadata", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; let mut ref_points: Vec<(f64, f64)> = Vec::new(); let mut videos_used = 0usize; for (json,) in &metadata_rows { let track: Vec<[f64; 2]> = match serde_json::from_str(json) { Ok(t) => t, Err(_) => continue, }; if track.is_empty() { continue; } videos_used += 1; for p in track { ref_points.push((p[0], p[1])); } } for c in &asset_coords { ref_points.push(*c); } if ref_points.is_empty() { return Err( "nothing to filter against. Import per-video metadata or assets first \ so the generator knows which Overpass-returned roads to keep." .into(), ); } let source = if videos_used > 0 && !asset_coords.is_empty() { "metadata+assets" } else if videos_used > 0 { "metadata" } else { "assets" }; // --- Overpass: bbox query, no around: list. --- let query = format!( "[out:json][timeout:180];\ (way[\"highway\"~\"{classes}\"]({mn_lat},{mn_lng},{mx_lat},{mx_lng}););\ out body geom;", classes = OSM_HIGHWAY_CLASSES, mn_lat = min_lat, mn_lng = min_lng, mx_lat = max_lat, mx_lng = max_lng, ); let url = overpass_url .unwrap_or_else(|| "https://overpass-api.de/api/interpreter".to_string()); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(240)) .user_agent(concat!( "kml_map_tool/", env!("CARGO_PKG_VERSION"), " (+https://overpass-api.de/api/interpreter)" )) .build() .map_err(|e| e.to_string())?; let resp = client .post(&url) .header("Accept", "application/json") .form(&[("data", &query)]) .send() .await .map_err(|e| format!("Overpass request failed: {}", e))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); let snippet: String = body.chars().take(400).collect(); return Err(format!("Overpass HTTP {} — {}", status, snippet)); } let body: serde_json::Value = resp.json().await.map_err(|e| format!("Overpass parse: {}", e))?; let elements = body .get("elements") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); let mut features: Vec = vec![]; for el in elements { if el.get("type").and_then(|v| v.as_str()) != Some("way") { continue; } let geom = match el.get("geometry").and_then(|v| v.as_array()) { Some(g) if g.len() >= 2 => g.clone(), _ => continue, }; let coords_v: Vec<[f64; 2]> = geom .iter() .filter_map(|p| { let lon = p.get("lon")?.as_f64()?; let lat = p.get("lat")?.as_f64()?; Some([lon, lat]) }) .collect(); if coords_v.len() < 2 { continue; } let tags = el.get("tags").cloned().unwrap_or(serde_json::json!({})); let id = el.get("id").cloned().unwrap_or(serde_json::json!(null)); let name = tags.get("name").cloned(); let highway = tags.get("highway").cloned(); let oneway = tags.get("oneway").and_then(|v| v.as_str()).map(|s| { match s { "yes" | "true" | "1" => 1i64, "-1" | "reverse" => -1, _ => 0, } }); let mut props = serde_json::Map::new(); props.insert("id".into(), id); if let Some(n) = name { props.insert("name".into(), n); } if let Some(h) = highway { props.insert("highway".into(), h); } if let Some(o) = oneway { props.insert("oneway".into(), serde_json::json!(o)); } features.push(serde_json::json!({ "type": "Feature", "geometry": { "type": "LineString", "coordinates": coords_v }, "properties": props, })); } // Post-filter: keep a way if any of its segments comes within // `threshold_m` of any reference point (metadata polyline points or // asset coords). Permissive — needs only ONE hit, vs the old // min_votes=2 which dropped sparse-asset highways. let kept = filter_features_by_reference_points( features, &ref_points, threshold_m, ); let fc = serde_json::json!({ "type": "FeatureCollection", "features": kept, }); let count = kept_count(&fc); tokio::fs::write(&path, serde_json::to_string_pretty(&fc).map_err(|e| e.to_string())?) .await .map_err(|e| e.to_string())?; Ok(GenerateOsmResult { ways_kept: count, source: source.to_string(), // Reuse points_used to surface the size of the reference set the // post-filter checked against (asset coords + every metadata vertex). points_used: ref_points.len(), videos_used, }) } fn kept_count(fc: &serde_json::Value) -> usize { fc.get("features") .and_then(|f| f.as_array()) .map(|a| a.len()) .unwrap_or(0) } // Squared-Euclidean point-to-segment distance in metres, using a local // equirectangular projection at the segment midpoint. Accurate to within // well under a metre at typical road-segment scales — fine for 30 m // thresholding. fn point_segment_distance_m( p_lng: f64, p_lat: f64, a_lng: f64, a_lat: f64, b_lng: f64, b_lat: f64, ) -> f64 { let lat0 = ((a_lat + b_lat) * 0.5).to_radians(); let m_per_lng = 111_320.0 * lat0.cos(); let m_per_lat = 111_320.0; let ax = a_lng * m_per_lng; let ay = a_lat * m_per_lat; let bx = b_lng * m_per_lng; let by = b_lat * m_per_lat; let px = p_lng * m_per_lng; let py = p_lat * m_per_lat; let dx = bx - ax; let dy = by - ay; let len2 = dx * dx + dy * dy; let (cx, cy) = if len2 < 1e-9 { (ax, ay) } else { let t = (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0); (ax + t * dx, ay + t * dy) }; let ex = px - cx; let ey = py - cy; (ex * ex + ey * ey).sqrt() } /// Permissive filter: keep a way if any of its segments comes within /// `threshold_m` of any reference point. Reference points are passed as /// `(lng, lat)`. Single hit is enough — designed for the bbox+filter flow /// where we want every road touched by either metadata polylines or assets, /// including highways with sparse asset coverage. /// /// Builds a spatial-hash grid keyed by `cell_deg = threshold_m / 111320`, /// then for each way walks its bbox cell + a 1-ring neighbourhood; at most /// one exact distance check per nearby reference point. ~O(W × P_local). fn filter_features_by_reference_points( features: Vec, ref_points: &[(f64, f64)], threshold_m: f64, ) -> Vec { use std::collections::HashMap; let cell_deg = (threshold_m / 111_320.0).max(1e-7); let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); for (i, (lng, lat)) in ref_points.iter().enumerate() { let gx = (lng / cell_deg).floor() as i64; let gy = (lat / cell_deg).floor() as i64; grid.entry((gx, gy)).or_default().push(i); } let mut out: Vec = Vec::new(); for feat in features { let pts: Vec<[f64; 2]> = feat .get("geometry") .and_then(|g| g.get("coordinates")) .and_then(|c| c.as_array()) .map(|arr| { arr.iter() .filter_map(|p| { let a = p.as_array()?; let lng = a.first()?.as_f64()?; let lat = a.get(1)?.as_f64()?; Some([lng, lat]) }) .collect() }) .unwrap_or_default(); if pts.len() < 2 { continue; } let mut keep = false; // Walk segments at sub-cell intervals so a long segment crossing a // populated cell can't slip through the grid. let step_deg = cell_deg * 0.5; 'segs: for w in pts.windows(2) { let a = w[0]; let b = w[1]; let dx = b[0] - a[0]; let dy = b[1] - a[1]; let len = (dx * dx + dy * dy).sqrt(); let steps = ((len / step_deg).ceil() as usize).max(1); for s in 0..=steps { let t = s as f64 / steps as f64; let lng = a[0] + dx * t; let lat = a[1] + dy * t; let gx = (lng / cell_deg).floor() as i64; let gy = (lat / cell_deg).floor() as i64; for dy_c in -1..=1 { for dx_c in -1..=1 { if let Some(idxs) = grid.get(&(gx + dx_c, gy + dy_c)) { for &idx in idxs { let p = ref_points[idx]; // Cheap equirectangular distance — accurate // enough for 50 m thresholding. let lat0 = ((lat + p.1) * 0.5).to_radians(); let m_per_lng = (111_320.0_f64 * lat0.cos()).max(1.0); let mx = (p.0 - lng) * m_per_lng; let my = (p.1 - lat) * 111_320.0; let d = (mx * mx + my * my).sqrt(); if d <= threshold_m { keep = true; break 'segs; } } } } } } } if keep { out.push(feat); } } out } #[tauri::command] pub async fn assets_bbox( state: State<'_, AppState>, ) -> Result, String> { // SELECT MIN/MAX over the per-row bbox columns gives the union bbox of // all live (non-deleted) assets in one indexed scan. Returns None if // the assets table is empty so the caller can fall back to the // visible viewport. let row: Option<( Option, Option, Option, Option, )> = sqlx::query_as( "SELECT MIN(min_lat), MAX(max_lat), MIN(min_lng), MAX(max_lng) FROM assets WHERE deleted = 0", ) .fetch_optional(&state.pool) .await .map_err(|e| e.to_string())?; Ok(match row { Some((Some(mn_lat), Some(mx_lat), Some(mn_lng), Some(mx_lng))) => { Some(BboxOut { min_lat: mn_lat, max_lat: mx_lat, min_lng: mn_lng, max_lng: mx_lng, }) } _ => None, }) } #[tauri::command] pub async fn generate_osm_for_bbox( min_lat: f64, min_lng: f64, max_lat: f64, max_lng: f64, path: String, overpass_url: Option, ) -> Result { let url = overpass_url .unwrap_or_else(|| "https://overpass-api.de/api/interpreter".to_string()); let query = format!( "[out:json][timeout:180];\ (way[\"highway\"~\"motorway|trunk|primary|secondary|tertiary|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link|residential|unclassified|service\"]\ ({},{},{},{}););\ out body geom;", min_lat, min_lng, max_lat, max_lng ); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(240)) // Overpass mirrors require an identifying User-Agent and reject // anonymous traffic with HTTP 406. They also content-negotiate, so // we need an Accept header that matches what they produce. .user_agent(concat!( "kml_map_tool/", env!("CARGO_PKG_VERSION"), " (+https://overpass-api.de/api/interpreter)" )) .build() .map_err(|e| e.to_string())?; // Send the query as application/x-www-form-urlencoded with `data=`. // Some Overpass mirrors return 406 Not Acceptable for raw bodies with no // Content-Type — the form encoding is the documented, stable shape. let resp = client .post(&url) .header("Accept", "application/json") .form(&[("data", &query)]) .send() .await .map_err(|e| format!("Overpass request failed: {}", e))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); // Trim huge HTML error pages that some mirrors return. let snippet: String = body.chars().take(400).collect(); return Err(format!("Overpass HTTP {} — {}", status, snippet)); } let body: serde_json::Value = resp.json().await.map_err(|e| format!("Overpass parse: {}", e))?; let elements = body .get("elements") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); let mut features: Vec = vec![]; for el in elements { if el.get("type").and_then(|v| v.as_str()) != Some("way") { continue; } let geom = match el.get("geometry").and_then(|v| v.as_array()) { Some(g) if g.len() >= 2 => g.clone(), _ => continue, }; let coords: Vec<[f64; 2]> = geom .iter() .filter_map(|p| { let lon = p.get("lon")?.as_f64()?; let lat = p.get("lat")?.as_f64()?; Some([lon, lat]) }) .collect(); if coords.len() < 2 { continue; } let tags = el.get("tags").cloned().unwrap_or(serde_json::Value::Null); let name = tags .get("name") .and_then(|v| v.as_str()) .or_else(|| tags.get("ref").and_then(|v| v.as_str())) .map(|s| s.to_string()); let highway = tags .get("highway") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let oneway: i64 = match tags.get("oneway").and_then(|v| v.as_str()) { Some("yes") | Some("true") | Some("1") => 1, Some("-1") | Some("reverse") => -1, _ => { if tags.get("junction").and_then(|v| v.as_str()) == Some("roundabout") || matches!( tags.get("highway").and_then(|v| v.as_str()), Some("motorway") | Some("motorway_link") ) { 1 } else { 0 } } }; features.push(serde_json::json!({ "type": "Feature", "geometry": {"type": "LineString", "coordinates": coords}, "properties": { "id": el.get("id"), "name": name, "highway": highway, "oneway": oneway, }, })); } let fc = serde_json::json!({ "type": "FeatureCollection", "features": features, }); tokio::fs::write(&path, serde_json::to_string(&fc).map_err(|e| e.to_string())?) .await .map_err(|e| e.to_string())?; Ok(features.len()) } #[tauri::command] pub async fn set_road_snap_ignored( id: i64, ignored: bool, state: State<'_, AppState>, ) -> Result { sqlx::query("UPDATE roads SET snap_ignored = ? WHERE id = ?") .bind(if ignored { 1 } else { 0 }) .bind(id) .execute(&state.pool) .await .map_err(|e| e.to_string())?; Ok(ignored) } #[tauri::command] pub async fn flip_road_direction( id: i64, state: State<'_, AppState>, ) -> Result { let row: (i64,) = sqlx::query_as("SELECT COALESCE(oneway, 0) FROM roads WHERE id = ?") .bind(id) .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; // 0 → 1 (set forward); 1 → -1; -1 → 1. let next = match row.0 { 0 => 1, v if v > 0 => -1, _ => 1, }; sqlx::query("UPDATE roads SET oneway = ?, modified = 1 WHERE id = ?") .bind(next) .bind(id) .execute(&state.pool) .await .map_err(|e| e.to_string())?; Ok(next) } #[tauri::command] pub async fn delete_roads_by_highway( classes: Vec, state: State<'_, AppState>, ) -> Result { if classes.is_empty() { return Ok(0); } let placeholders = vec!["?"; classes.len()].join(","); let sql = format!( "DELETE FROM roads WHERE highway IN ({})", placeholders ); let mut q = sqlx::query(&sql); for c in &classes { q = q.bind(c); } let res = q.execute(&state.pool).await.map_err(|e| e.to_string())?; Ok(res.rows_affected() as usize) } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct RoadClassCount { pub highway: Option, pub n: i64, } #[tauri::command] pub async fn list_road_classes( state: State<'_, AppState>, ) -> Result, String> { sqlx::query_as::<_, RoadClassCount>( "SELECT highway, COUNT(*) AS n FROM roads GROUP BY highway ORDER BY n DESC", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn create_osm_road( name: Option, coords: Vec<[f64; 2]>, state: State<'_, AppState>, ) -> Result { if coords.len() < 2 { return Err("a road must have at least 2 vertices".into()); } let mut min_lat = f64::INFINITY; let mut max_lat = f64::NEG_INFINITY; let mut min_lng = f64::INFINITY; let mut max_lng = f64::NEG_INFINITY; for c in &coords { let lng = c[0]; let lat = c[1]; if !lng.is_finite() || !lat.is_finite() { return Err("non-finite coordinate".into()); } if lat < min_lat { min_lat = lat; } if lat > max_lat { max_lat = lat; } if lng < min_lng { min_lng = lng; } if lng > max_lng { max_lng = lng; } } let geom = serde_json::json!({ "type": "LineString", "coordinates": coords, }); let row: (i64,) = sqlx::query_as( "INSERT INTO roads(name, geojson, min_lat, max_lat, min_lng, max_lng, modified) VALUES (?, ?, ?, ?, ?, ?, 1) RETURNING id", ) .bind(&name) .bind(geom.to_string()) .bind(min_lat) .bind(max_lat) .bind(min_lng) .bind(max_lng) .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; Ok(row.0) } #[tauri::command] pub async fn delete_osm_road( id: i64, state: State<'_, AppState>, ) -> Result<(), String> { sqlx::query("DELETE FROM roads WHERE id = ?") .bind(id) .execute(&state.pool) .await .map_err(|e| e.to_string())?; Ok(()) } /// Remove a list of vertex indices from a road's polyline. Indices are /// zero-based and refer to the *current* coords array. The road must keep at /// least 2 vertices afterwards. #[tauri::command] pub async fn delete_road_vertices( id: i64, indices: Vec, state: State<'_, AppState>, ) -> Result { let row: (String,) = sqlx::query_as("SELECT geojson FROM roads WHERE id = ?") .bind(id) .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; let val: serde_json::Value = serde_json::from_str(&row.0).map_err(|e| e.to_string())?; let arr = val .get("coordinates") .and_then(|c| c.as_array()) .ok_or_else(|| "road has no coordinates".to_string())?; let mut coords: Vec<[f64; 2]> = arr .iter() .filter_map(|p| p.as_array()) .filter_map(|p| { Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?]) }) .collect(); use std::collections::HashSet; let drop: HashSet = indices.into_iter().collect(); let new_coords: Vec<[f64; 2]> = coords .drain(..) .enumerate() .filter(|(i, _)| !drop.contains(i)) .map(|(_, c)| c) .collect(); if new_coords.len() < 2 { return Err( "removing those vertices would leave < 2 — road needs at least 2".into(), ); } let removed = drop.len(); update_osm_road(id, new_coords, state).await?; Ok(removed) } fn coords_bbox(coords: &[[f64; 2]]) -> (f64, f64, f64, f64) { let mut mn_lat = f64::INFINITY; let mut mx_lat = f64::NEG_INFINITY; let mut mn_lng = f64::INFINITY; let mut mx_lng = f64::NEG_INFINITY; for &[lng, lat] in coords { if lat < mn_lat { mn_lat = lat; } if lat > mx_lat { mx_lat = lat; } if lng < mn_lng { mn_lng = lng; } if lng > mx_lng { mx_lng = lng; } } (mn_lat, mx_lat, mn_lng, mx_lng) } /// Delete the section of a road that lies between two (or more) marked /// vertices. Behaviour: /// - sort the indices, take min and max /// - drop everything strictly between them; keep [0..=min] (left) and /// [max..end] (right) as the survivors /// - if both halves have ≥2 vertices → split into TWO road rows /// (original updated to left, new row inserted for right with the same /// name / oneway / highway / snap_ignored carried over) /// - if only one half has ≥2 vertices → original is shrunk to that half /// - if neither half has ≥2 vertices → error /// Returns the ids of the resulting road rows (1 or 2). #[tauri::command] pub async fn delete_road_section( id: i64, indices: Vec, state: State<'_, AppState>, ) -> Result, String> { if indices.len() < 2 { return Err("need at least 2 marked vertices to define a section".into()); } let row: (Option, String, i64, Option, i64) = sqlx::query_as( "SELECT name, geojson, COALESCE(oneway, 0), highway, COALESCE(snap_ignored, 0) FROM roads WHERE id = ?", ) .bind(id) .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; let (name, geojson, oneway, highway, snap_ignored) = row; let val: serde_json::Value = serde_json::from_str(&geojson) .map_err(|e| format!("invalid geojson: {}", e))?; let arr = val .get("coordinates") .and_then(|c| c.as_array()) .ok_or_else(|| "road has no coordinates".to_string())?; let coords: Vec<[f64; 2]> = arr .iter() .filter_map(|p| p.as_array()) .filter_map(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])) .collect(); if coords.len() < 2 { return Err("road has < 2 coordinates".into()); } let mut sorted = indices; sorted.sort_unstable(); sorted.dedup(); if sorted.len() < 2 { return Err("need at least 2 distinct marked vertices".into()); } let min_i = sorted[0]; let max_i = *sorted.last().unwrap(); if max_i >= coords.len() { return Err(format!( "vertex index {} out of range (road has {} vertices)", max_i, coords.len() )); } // Adjacent marks (max_i == min_i + 1) sever the single edge between them: // left keeps [..=min_i], right starts at max_i — both halves still hold both // marked vertices, just the connecting edge is dropped. Non-adjacent marks // additionally drop the strictly-between vertices. let left: Vec<[f64; 2]> = coords[0..=min_i].to_vec(); let right: Vec<[f64; 2]> = coords[max_i..].to_vec(); let left_ok = left.len() >= 2; let right_ok = right.len() >= 2; if !left_ok && !right_ok { return Err( "section deletion would leave < 2 vertices on both sides".into(), ); } let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut result_ids: Vec = Vec::new(); // Helper: shrink the original road to the given coords, fix r-tree. async fn shrink( tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, id: i64, coords: &[[f64; 2]], ) -> Result<(), String> { let (mn_lat, mx_lat, mn_lng, mx_lng) = coords_bbox(coords); let geom = serde_json::json!({ "type": "LineString", "coordinates": coords, }); sqlx::query( "UPDATE roads SET geojson = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = 1 WHERE id = ?", ) .bind(geom.to_string()) .bind(mn_lat).bind(mx_lat).bind(mn_lng).bind(mx_lng) .bind(id) .execute(&mut **tx) .await .map_err(|e| e.to_string())?; // R-tree has only INSERT/DELETE triggers; emulate UPDATE. sqlx::query("DELETE FROM roads_rtree WHERE id = ?") .bind(id) .execute(&mut **tx) .await .map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO roads_rtree(id, min_lat, max_lat, min_lng, max_lng) VALUES (?, ?, ?, ?, ?)", ) .bind(id).bind(mn_lat).bind(mx_lat).bind(mn_lng).bind(mx_lng) .execute(&mut **tx) .await .map_err(|e| e.to_string())?; Ok(()) } if left_ok && right_ok { // Split: original keeps the left half; right half becomes a new row // carrying the same name/oneway/highway/snap_ignored. shrink(&mut tx, id, &left).await?; result_ids.push(id); let (rn_lat, rx_lat, rn_lng, rx_lng) = coords_bbox(&right); let geom = serde_json::json!({ "type": "LineString", "coordinates": right, }); let inserted: (i64,) = sqlx::query_as( "INSERT INTO roads(name, geojson, min_lat, max_lat, min_lng, max_lng, oneway, highway, snap_ignored, modified) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) RETURNING id", ) .bind(&name) .bind(geom.to_string()) .bind(rn_lat).bind(rx_lat).bind(rn_lng).bind(rx_lng) .bind(oneway) .bind(&highway) .bind(snap_ignored) .fetch_one(&mut *tx) .await .map_err(|e| e.to_string())?; result_ids.push(inserted.0); } else if left_ok { shrink(&mut tx, id, &left).await?; result_ids.push(id); } else { shrink(&mut tx, id, &right).await?; result_ids.push(id); } tx.commit().await.map_err(|e| e.to_string())?; Ok(result_ids) } #[derive(Debug, Serialize)] pub struct LassoRoadDeleteResult { pub roads_examined: usize, pub roads_deleted: usize, pub roads_split: usize, pub new_road_ids: Vec, // Snapshots of every road that was mutated (fully deleted or partially // clipped). The OSM-edit undo replays these via restore_road_state, which // re-inserts rows whose ids no longer exist. pub before_snapshots: Vec, } /// Bulk-delete road sections that fall inside a lasso polygon. /// /// `polygon` is a sequence of `[lng, lat]` points in any order (closing point /// optional — implicitly closed). For each road that overlaps the polygon's /// bbox we walk segment-by-segment, intersecting each road edge with the /// polygon boundary. Outside-pieces survive as new road rows (with boundary /// crossings as fresh vertices); inside-pieces are dropped. This handles /// concave lassos correctly — a segment that crosses the boundary multiple /// times keeps every outside sub-segment. #[tauri::command] pub async fn delete_roads_in_lasso( polygon: Vec<[f64; 2]>, state: State<'_, AppState>, ) -> Result { if polygon.len() < 3 { return Err("lasso polygon needs at least 3 points".into()); } // Polygon bbox (lng, lat) — coarse pre-filter via R-tree. let mut p_mn_lat = f64::INFINITY; let mut p_mx_lat = f64::NEG_INFINITY; let mut p_mn_lng = f64::INFINITY; let mut p_mx_lng = f64::NEG_INFINITY; for &[lng, lat] in &polygon { if lat < p_mn_lat { p_mn_lat = lat; } if lat > p_mx_lat { p_mx_lat = lat; } if lng < p_mn_lng { p_mn_lng = lng; } if lng > p_mx_lng { p_mx_lng = lng; } } let candidate_rows: Vec<(i64, Option, String, i64, Option, i64, i64)> = sqlx::query_as( "SELECT r.id, r.name, r.geojson, COALESCE(r.oneway, 0), r.highway, COALESCE(r.snap_ignored, 0), COALESCE(r.modified, 0) FROM roads r JOIN roads_rtree x ON x.id = r.id WHERE x.max_lat >= ? AND x.min_lat <= ? AND x.max_lng >= ? AND x.min_lng <= ?", ) .bind(p_mn_lat) .bind(p_mx_lat) .bind(p_mn_lng) .bind(p_mx_lng) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; fn point_in_polygon(lng: f64, lat: f64, poly: &[[f64; 2]]) -> bool { // Standard ray-casting. Boundary cases default to "inside" — fine for // lasso UX since users expect a generous interpretation. let mut inside = false; let n = poly.len(); let mut j = n - 1; for i in 0..n { let (xi, yi) = (poly[i][0], poly[i][1]); let (xj, yj) = (poly[j][0], poly[j][1]); let cond = (yi > lat) != (yj > lat) && lng < (xj - xi) * (lat - yi) / (yj - yi + 1e-15) + xi; if cond { inside = !inside; } j = i; } inside } // Segment vs. polygon-edge intersection. Returns the parameter t∈(0,1) at // which segment a→b crosses edge p→q, or None if no proper crossing. // Endpoints touching (t=0 / t=1) are treated as non-crossings to avoid // double-counting at shared vertices. fn segment_intersect_t( a: [f64; 2], b: [f64; 2], p: [f64; 2], q: [f64; 2], ) -> Option { let rx = b[0] - a[0]; let ry = b[1] - a[1]; let sx = q[0] - p[0]; let sy = q[1] - p[1]; let denom = rx * sy - ry * sx; if denom.abs() < 1e-15 { return None; } let dx = p[0] - a[0]; let dy = p[1] - a[1]; let t = (dx * sy - dy * sx) / denom; let u = (dx * ry - dy * rx) / denom; if t > 1e-12 && t < 1.0 - 1e-12 && u >= -1e-12 && u <= 1.0 + 1e-12 { Some(t) } else { None } } fn lerp(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] { [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] } let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut roads_deleted = 0usize; let mut roads_split = 0usize; let mut new_road_ids: Vec = Vec::new(); let mut before_snapshots: Vec = Vec::new(); let examined = candidate_rows.len(); for (id, name, geojson, oneway, highway, snap_ignored, modified) in candidate_rows { let val: serde_json::Value = match serde_json::from_str(&geojson) { Ok(v) => v, Err(_) => continue, }; let arr = match val.get("coordinates").and_then(|c| c.as_array()) { Some(a) => a, None => continue, }; let coords: Vec<[f64; 2]> = arr .iter() .filter_map(|p| p.as_array()) .filter_map(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])) .collect(); if coords.len() < 2 { continue; } let inside_flags: Vec = coords .iter() .map(|c| point_in_polygon(c[0], c[1], &polygon)) .collect(); if inside_flags.iter().all(|&b| b) { // Every vertex inside; assume edges are too. Delete outright. before_snapshots.push(RoadSnapshot { id, name: name.clone(), geojson: geojson.clone(), oneway, highway: highway.clone(), snap_ignored, modified, }); sqlx::query("DELETE FROM roads WHERE id = ?") .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; roads_deleted += 1; continue; } // Walk each edge, splitting on polygon-boundary crossings. Each // sub-edge is wholly inside or wholly outside; outside sub-edges are // appended to the current "kept" run and inside sub-edges close it. let mut kept_runs: Vec> = Vec::new(); let mut cur: Vec<[f64; 2]> = Vec::new(); let push_outside = |cur: &mut Vec<[f64; 2]>, p: [f64; 2]| { // Avoid duplicating the previous endpoint (consecutive segments // share a vertex). if cur.last().map(|q| (q[0] - p[0]).abs() < 1e-12 && (q[1] - p[1]).abs() < 1e-12) .unwrap_or(false) { return; } cur.push(p); }; let close_run = |kept_runs: &mut Vec>, cur: &mut Vec<[f64; 2]>| { if cur.len() >= 2 { kept_runs.push(std::mem::take(cur)); } else { cur.clear(); } }; for i in 0..coords.len() - 1 { let a = coords[i]; let b = coords[i + 1]; // Collect all boundary crossings within this segment. let mut ts: Vec = Vec::new(); let n = polygon.len(); for k in 0..n { let p = polygon[k]; let q = polygon[(k + 1) % n]; if let Some(t) = segment_intersect_t(a, b, p, q) { ts.push(t); } } ts.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal)); ts.dedup_by(|x, y| (*x - *y).abs() < 1e-9); // Sub-segment endpoints along a→b: 0, t1, t2, …, 1 let mut breakpoints: Vec = vec![0.0]; breakpoints.extend(ts); breakpoints.push(1.0); // Inside/outside alternates, starting from inside_flags[i]. let mut currently_inside = inside_flags[i]; for w in breakpoints.windows(2) { let (t0, t1) = (w[0], w[1]); if (t1 - t0).abs() < 1e-12 { currently_inside = !currently_inside; continue; } let p0 = lerp(a, b, t0); let p1 = lerp(a, b, t1); if currently_inside { // Closes any open kept-run; the inside piece is dropped. close_run(&mut kept_runs, &mut cur); } else { // Outside piece: append both endpoints (dedup against last). push_outside(&mut cur, p0); push_outside(&mut cur, p1); } currently_inside = !currently_inside; } } close_run(&mut kept_runs, &mut cur); // The road had at least one inside vertex (we early-returned the // wholly-inside case above), so SOMETHING changed: snapshot, delete // the original, and re-insert the surviving outside runs. before_snapshots.push(RoadSnapshot { id, name: name.clone(), geojson: geojson.clone(), oneway, highway: highway.clone(), snap_ignored, modified, }); sqlx::query("DELETE FROM roads WHERE id = ?") .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; if kept_runs.is_empty() { roads_deleted += 1; continue; } for run in kept_runs { let (mn_lat, mx_lat, mn_lng, mx_lng) = coords_bbox(&run); let geom = serde_json::json!({ "type": "LineString", "coordinates": run, }); let inserted: (i64,) = sqlx::query_as( "INSERT INTO roads(name, geojson, min_lat, max_lat, min_lng, max_lng, oneway, highway, snap_ignored, modified) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) RETURNING id", ) .bind(&name) .bind(geom.to_string()) .bind(mn_lat).bind(mx_lat).bind(mn_lng).bind(mx_lng) .bind(oneway) .bind(&highway) .bind(snap_ignored) .fetch_one(&mut *tx) .await .map_err(|e| e.to_string())?; new_road_ids.push(inserted.0); } roads_split += 1; } tx.commit().await.map_err(|e| e.to_string())?; Ok(LassoRoadDeleteResult { roads_examined: examined, roads_deleted, roads_split, new_road_ids, before_snapshots, }) } /// Douglas-Peucker simplification of a road's polyline. Tolerance is in metres. /// Converted to a degrees epsilon at the road's centroid latitude — accurate /// enough for cleanup tolerances of 0.5–10 m. #[tauri::command] pub async fn simplify_road( id: i64, tolerance_m: f64, state: State<'_, AppState>, ) -> Result<(usize, usize), String> { if tolerance_m <= 0.0 { return Err("tolerance must be > 0".into()); } let row: (String,) = sqlx::query_as("SELECT geojson FROM roads WHERE id = ?") .bind(id) .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; let val: serde_json::Value = serde_json::from_str(&row.0).map_err(|e| e.to_string())?; let arr = val .get("coordinates") .and_then(|c| c.as_array()) .ok_or_else(|| "road has no coordinates".to_string())?; let coords: Vec<[f64; 2]> = arr .iter() .filter_map(|p| p.as_array()) .filter_map(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])) .collect(); let before = coords.len(); if before < 3 { return Ok((before, before)); } // Convert tolerance metres → degrees, with a cos-lat correction for lng. let mid_lat = coords[before / 2][1]; let m_per_deg_lat = 111_000.0_f64; let m_per_deg_lng = 111_000.0_f64 * mid_lat.to_radians().cos().abs().max(0.1); // Project to a local "metres" space so DP epsilon is uniform. let projected: Vec<[f64; 2]> = coords .iter() .map(|c| [c[0] * m_per_deg_lng, c[1] * m_per_deg_lat]) .collect(); use geo::algorithm::Simplify; let line = LineString::from( projected .iter() .map(|c| (c[0], c[1])) .collect::>(), ); let simplified = line.simplify(tolerance_m); let new_coords: Vec<[f64; 2]> = simplified .0 .iter() .map(|c| [c.x / m_per_deg_lng, c.y / m_per_deg_lat]) .collect(); if new_coords.len() < 2 { return Err("simplification would leave < 2 vertices".into()); } let after = new_coords.len(); update_osm_road(id, new_coords, state).await?; Ok((before, after)) } #[derive(Debug, Serialize, Deserialize)] pub struct RoadSnapshot { pub id: i64, pub name: Option, pub geojson: String, pub oneway: i64, pub highway: Option, pub snap_ignored: i64, // Snapshot the modified flag too — undoing a mutation should also revert // the green-highlight indicator to its pre-mutation state. #[serde(default)] pub modified: i64, } /// Capture the full state of a small set of roads so the frontend can revert /// edits via `restore_road_state`. Used by the in-session OSM edit undo /// (3-step ring buffer); not part of the global audit log. #[tauri::command] pub async fn osm_road_snapshot( ids: Vec, state: State<'_, AppState>, ) -> Result, String> { if ids.is_empty() { return Ok(vec![]); } let mut out: Vec = Vec::with_capacity(ids.len()); for chunk in ids.chunks(500) { let placeholders = vec!["?"; chunk.len()].join(","); let sql = format!( "SELECT id, name, geojson, COALESCE(oneway, 0), highway, COALESCE(snap_ignored, 0), COALESCE(modified, 0) FROM roads WHERE id IN ({})", placeholders ); let mut q = sqlx::query_as::< _, (i64, Option, String, i64, Option, i64, i64), >(&sql); for &i in chunk { q = q.bind(i); } let rows = q.fetch_all(&state.pool).await.map_err(|e| e.to_string())?; for (id, name, geojson, oneway, highway, snap_ignored, modified) in rows { out.push(RoadSnapshot { id, name, geojson, oneway, highway, snap_ignored, modified, }); } } Ok(out) } /// Replay a list of snapshots back into `roads`. Upserts by id: existing rows /// are updated in place; deleted rows are re-inserted with their original id /// (lasso delete uses this path). The R-tree trigger handles inserts; updates /// are mirrored manually because there's no AFTER UPDATE trigger. #[tauri::command] pub async fn restore_road_state( snapshots: Vec, state: State<'_, AppState>, ) -> Result { if snapshots.is_empty() { return Ok(0); } let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let mut applied = 0usize; for s in snapshots { let line = match parse_linestring(&s.geojson) { Some(l) => l, None => continue, }; let (mut mn_lat, mut mx_lat) = (f64::INFINITY, f64::NEG_INFINITY); let (mut mn_lng, mut mx_lng) = (f64::INFINITY, f64::NEG_INFINITY); for c in &line.0 { if c.y < mn_lat { mn_lat = c.y; } if c.y > mx_lat { mx_lat = c.y; } if c.x < mn_lng { mn_lng = c.x; } if c.x > mx_lng { mx_lng = c.x; } } let res = sqlx::query( "UPDATE roads SET name = ?, geojson = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, oneway = ?, highway = ?, snap_ignored = ?, modified = ? WHERE id = ?", ) .bind(&s.name) .bind(&s.geojson) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(s.oneway) .bind(&s.highway) .bind(s.snap_ignored) .bind(s.modified) .bind(s.id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; if res.rows_affected() > 0 { // Keep R-tree in sync — no UPDATE trigger on roads, so emulate // via delete+insert (matches update_osm_road). sqlx::query("DELETE FROM roads_rtree WHERE id = ?") .bind(s.id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO roads_rtree(id, min_lat, max_lat, min_lng, max_lng) VALUES (?, ?, ?, ?, ?)", ) .bind(s.id) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; applied += 1; } else { // Row no longer exists — re-insert with the original id. The // AFTER INSERT trigger on roads will populate roads_rtree. sqlx::query( "INSERT INTO roads(id, name, geojson, min_lat, max_lat, min_lng, max_lng, oneway, highway, snap_ignored, modified) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(s.id) .bind(&s.name) .bind(&s.geojson) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(s.oneway) .bind(&s.highway) .bind(s.snap_ignored) .bind(s.modified) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; applied += 1; } } tx.commit().await.map_err(|e| e.to_string())?; Ok(applied) } #[tauri::command] pub async fn update_osm_road( id: i64, coords: Vec<[f64; 2]>, state: State<'_, AppState>, ) -> Result<(), String> { if coords.len() < 2 { return Err("a road must have at least 2 vertices".into()); } let mut min_lat = f64::INFINITY; let mut max_lat = f64::NEG_INFINITY; let mut min_lng = f64::INFINITY; let mut max_lng = f64::NEG_INFINITY; for c in &coords { let lng = c[0]; let lat = c[1]; if !lng.is_finite() || !lat.is_finite() { return Err("non-finite coordinate".into()); } if lat < min_lat { min_lat = lat; } if lat > max_lat { max_lat = lat; } if lng < min_lng { min_lng = lng; } if lng > max_lng { max_lng = lng; } } let geom = serde_json::json!({ "type": "LineString", "coordinates": coords, }); sqlx::query( "UPDATE roads SET geojson = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = 1 WHERE id = ?", ) .bind(geom.to_string()) .bind(min_lat) .bind(max_lat) .bind(min_lng) .bind(max_lng) .bind(id) .execute(&state.pool) .await .map_err(|e| e.to_string())?; // Refresh R-tree row (no UPDATE trigger; emulate via delete+insert). sqlx::query("DELETE FROM roads_rtree WHERE id = ?") .bind(id) .execute(&state.pool) .await .map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO roads_rtree(id, min_lat, max_lat, min_lng, max_lng) VALUES (?, ?, ?, ?, ?)", ) .bind(id) .bind(min_lat) .bind(max_lat) .bind(min_lng) .bind(max_lng) .execute(&state.pool) .await .map_err(|e| e.to_string())?; Ok(()) } fn parse_linestring(geojson: &str) -> Option> { let v: serde_json::Value = serde_json::from_str(geojson).ok()?; let coords = v.get("coordinates")?.as_array()?; let mut out = Vec::with_capacity(coords.len()); for c in coords { let arr = c.as_array()?; if arr.len() < 2 { return None; } out.push(Coord { x: arr[0].as_f64()?, y: arr[1].as_f64()?, }); } if out.len() < 2 { return None; } Some(LineString(out)) } /// Spatial grid over flattened road segments for the bulk snap path. Each /// segment is bucketed into every cell its bbox intersects; the per-asset /// query gathers segment indices from the cell containing the asset plus a /// 1-ring neighbourhood, so any segment within `max_d` of the asset is /// guaranteed to be in the candidate set. Cuts bulk snap from /// O(assets × all_segments) to O(assets × segments_per_cell). struct SnapIndex { segments: Vec<(Coord, Coord, i32)>, grid: std::collections::HashMap<(i64, i64), Vec>, cell_deg: f64, } impl SnapIndex { fn build(roads: &[(LineString, i32)], max_d: f64) -> Self { // cell ≈ max_d in latitude — the 3×3 query then spans 3·max_d, which // comfortably covers any segment within max_d of the asset. let cell_deg = (max_d / 111_320.0).max(1e-7); let mut segments: Vec<(Coord, Coord, i32)> = Vec::new(); let mut grid: std::collections::HashMap<(i64, i64), Vec> = std::collections::HashMap::new(); for (line, ow) in roads { for seg in line.lines() { let idx = segments.len(); segments.push((seg.start, seg.end, *ow)); let mn_lat = seg.start.y.min(seg.end.y); let mx_lat = seg.start.y.max(seg.end.y); let mn_lng = seg.start.x.min(seg.end.x); let mx_lng = seg.start.x.max(seg.end.x); let gy0 = (mn_lat / cell_deg).floor() as i64; let gy1 = (mx_lat / cell_deg).floor() as i64; let gx0 = (mn_lng / cell_deg).floor() as i64; let gx1 = (mx_lng / cell_deg).floor() as i64; for gy in gy0..=gy1 { for gx in gx0..=gx1 { grid.entry((gx, gy)).or_default().push(idx); } } } } Self { segments, grid, cell_deg, } } fn candidates( &self, lng: f64, lat: f64, ) -> Vec<(Coord, Coord, i32)> { let gx = (lng / self.cell_deg).floor() as i64; let gy = (lat / self.cell_deg).floor() as i64; // Cell is `cell_deg` wide in BOTH lat and lng degrees, but lng-degrees // shrink in metres as |lat| → 90. The ring in lat is fine at 1 // (cell_deg ≈ max_d in metres). For lng, span one cell in metres is // `cell_deg × 111_320 × cos(lat)` = `max_d × cos(lat)`, so we need // ring_lng cells such that `ring_lng × max_d × cos(lat) ≥ max_d`, // i.e. ring_lng ≥ 1/cos(lat). Floor at 1, clamp at a sane upper to // avoid pathological lookups very close to the poles. let cos_l = (lat * std::f64::consts::PI / 180.0) .cos() .abs() .max(0.05); let ring_lng = (1.0 / cos_l).ceil() as i64; let ring_lng = ring_lng.max(1).min(20); let ring_lat: i64 = 1; let mut idxs: Vec = Vec::new(); for dy in -ring_lat..=ring_lat { for dx in -ring_lng..=ring_lng { if let Some(v) = self.grid.get(&(gx + dx, gy + dy)) { idxs.extend(v.iter().copied()); } } } idxs.sort_unstable(); idxs.dedup(); idxs.into_iter().map(|i| self.segments[i]).collect() } } /// Snap a single (lng, lat) to the closest segment in `candidates`, then /// apply a side-aware perpendicular offset. Returns Ok((new_lat, new_lng)) /// when the closest road is within `max_d`. The sign convention: /// side="Left"/"LHS"/"L" → +perpendicular relative to vehicle heading /// side="Right"/"RHS"/"R" → −perpendicular /// otherwise → fall back to position-derived sign fn snap_point_with_offset( candidates: &[(LineString, i32)], side: Option<&str>, metadata_track: Option<&[[f64; 2]]>, lng: f64, lat: f64, offset: f64, max_d: f64, ) -> Result<(f64, f64), String> { let segs = candidates.iter().flat_map(|(l, ow)| { l.lines().map(move |s| (s.start, s.end, *ow)) }); snap_point_with_segments(segs, side, metadata_track, lng, lat, offset, max_d) } /// Generic segment-iterating core used by both `snap_point_with_offset` /// (single asset, R-tree pre-filtered) and the bulk snap path with a /// `SnapIndex` that pre-filters segments via a spatial grid. Avoids the /// O(assets × roads × segments) blow-up of the bulk snap by giving each /// asset only the segments in its 3×3 grid neighbourhood. fn snap_point_with_segments( segs: I, side: Option<&str>, metadata_track: Option<&[[f64; 2]]>, lng: f64, lat: f64, offset: f64, max_d: f64, ) -> Result<(f64, f64), String> where I: IntoIterator, Coord, i32)>, { let pt = Point::new(lng, lat); let mut best: Option<(f64, Point, (f64, f64), (f64, f64), i32)> = None; for (start, end, ow) in segs { let seg_ls = LineString(vec![start, end]); let snapped = match seg_ls.closest_point(&pt) { Closest::SinglePoint(p) | Closest::Intersection(p) => p, _ => continue, }; let d = Geodesic.distance(pt, snapped); if best.as_ref().map(|b| d < b.0).unwrap_or(true) { best = Some((d, snapped, (start.x, start.y), (end.x, end.y), ow)); } } let (dist, sp, seg_start, seg_end, oneway) = best.ok_or_else(|| "no usable roads".to_string())?; if dist > max_d { return Err(format!( "nearest road is {:.1} m away (>{:.0} m limit)", dist, max_d )); } let mut new_lng = sp.x(); let mut new_lat = sp.y(); if offset > 0.0 { let lat0 = new_lat; let m_per_lat = 111_320.0_f64; let m_per_lng = 111_320.0_f64 * (lat0 * std::f64::consts::PI / 180.0).cos().abs().max(1e-6); // Determine vehicle heading direction. Priority: // 1. metadata polyline (true vehicle GPS) // 2. road oneway tag (segment direction × oneway sign) // 3. fall back to raw segment direction (sign-from-position later) let metadata_heading = metadata_track .and_then(|t| vehicle_heading_at(t, new_lng, new_lat)); let heading_known_for_side = metadata_heading.is_some() || oneway != 0; let (dx, dy) = match metadata_heading { Some((vx, vy)) => (vx * m_per_lng, vy * m_per_lat), None => { let raw_x = (seg_end.0 - seg_start.0) * m_per_lng; let raw_y = (seg_end.1 - seg_start.1) * m_per_lat; let s = if oneway < 0 { -1.0 } else { 1.0 }; (raw_x * s, raw_y * s) } }; let len = (dx * dx + dy * dy).sqrt().max(1e-9); let px = -dy / len; let py = dx / len; // Use the asset's annotated side only if we actually know which way // traffic flows (metadata or oneway). Otherwise raw segment direction // is arbitrary and the side label would flip half the time. let sign = if heading_known_for_side { match side.map(|s| s.to_uppercase()) { Some(ref s) if s == "LEFT" || s == "LHS" || s == "L" => 1.0, Some(ref s) if s == "RIGHT" || s == "RHS" || s == "R" => -1.0, _ => { let ax = (lng - new_lng) * m_per_lng; let ay = (lat - new_lat) * m_per_lat; let dot = px * ax + py * ay; if dot >= 0.0 { 1.0 } else { -1.0 } } } } else { let ax = (lng - new_lng) * m_per_lng; let ay = (lat - new_lat) * m_per_lat; let dot = px * ax + py * ay; if dot >= 0.0 { 1.0 } else { -1.0 } }; new_lng += sign * px * offset / m_per_lng; new_lat += sign * py * offset / m_per_lat; } Ok((new_lat, new_lng)) } /// Local vehicle heading at the metadata sample nearest to (lng, lat). /// Returns (dx_lng, dy_lat) — unnormalized direction vector. None if track too /// short to derive a heading. fn vehicle_heading_at( track: &[[f64; 2]], lng: f64, lat: f64, ) -> Option<(f64, f64)> { if track.len() < 2 { return None; } let mut best_i = 0usize; let mut best_d2 = f64::INFINITY; for (i, p) in track.iter().enumerate() { let dx = p[0] - lng; let dy = p[1] - lat; let d2 = dx * dx + dy * dy; if d2 < best_d2 { best_d2 = d2; best_i = i; } } let a = track[best_i.saturating_sub(1)]; let b = track[(best_i + 1).min(track.len() - 1)]; Some((b[0] - a[0], b[1] - a[1])) } #[tauri::command] pub async fn snap_to_road( asset_id: i64, max_distance_m: Option, offset_m: Option, metadata_track: Option>, state: State<'_, AppState>, ) -> Result<(f64, f64), String> { let max_d = max_distance_m.unwrap_or(50.0); let offset = offset_m.unwrap_or(0.0).max(0.0); let row: Asset = sqlx::query_as( "SELECT id, row_id, asset_type, asset_name, video_name, lat, lng, start_lat, start_lng, end_lat, end_lng, image_path, image_path1, image_path2, image_path3, deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked FROM assets WHERE id = ?", ) .bind(asset_id) .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; // Build vertex list: fixed → just mid; range → start + mid + end. let mut targets: Vec<(&'static str, f64, f64)> = vec![]; if row.asset_type == "range" { if let (Some(la), Some(ln)) = (row.start_lat, row.start_lng) { targets.push(("start", la, ln)); } if let (Some(la), Some(ln)) = (row.lat, row.lng) { targets.push(("mid", la, ln)); } if let (Some(la), Some(ln)) = (row.end_lat, row.end_lng) { targets.push(("end", la, ln)); } } else if let (Some(la), Some(ln)) = (row.lat, row.lng) { targets.push(("mid", la, ln)); } if targets.is_empty() { return Err("asset has no coordinate".into()); } // Use the asset's overall bbox padded by max_d for the R-tree candidate query. let bbox_min_lat = targets.iter().map(|t| t.1).fold(f64::INFINITY, f64::min); let bbox_max_lat = targets.iter().map(|t| t.1).fold(f64::NEG_INFINITY, f64::max); let bbox_min_lng = targets.iter().map(|t| t.2).fold(f64::INFINITY, f64::min); let bbox_max_lng = targets.iter().map(|t| t.2).fold(f64::NEG_INFINITY, f64::max); let pad_lat = max_d / 111_320.0; let pad_lng = max_d / (111_320.0 * (bbox_min_lat * std::f64::consts::PI / 180.0) .cos() .abs() .max(1e-6)); let candidate_rows: Vec<(i64, String, i64)> = sqlx::query_as( "SELECT r.id, r.geojson, COALESCE(r.oneway, 0) FROM roads r JOIN roads_rtree x ON x.id = r.id WHERE x.max_lat >= ? AND x.min_lat <= ? AND x.max_lng >= ? AND x.min_lng <= ? AND COALESCE(r.snap_ignored, 0) = 0", ) .bind(bbox_min_lat - pad_lat) .bind(bbox_max_lat + pad_lat) .bind(bbox_min_lng - pad_lng) .bind(bbox_max_lng + pad_lng) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; if candidate_rows.is_empty() { return Err(format!("no roads within ~{:.0} m", max_d)); } let candidates: Vec<(LineString, i32)> = candidate_rows .into_iter() .filter_map(|(_, gj, ow)| parse_linestring(&gj).map(|l| (l, ow as i32))) .collect(); let track = metadata_track.as_deref(); // Snap each vertex independently. let mut snapped_pts: Vec<(&'static str, f64, f64)> = Vec::with_capacity(targets.len()); for (label, vlat, vlng) in &targets { let (nlat, nlng) = snap_point_with_offset( &candidates, row.side.as_deref(), track, *vlng, *vlat, offset, max_d, )?; snapped_pts.push((*label, nlat, nlng)); } // Idempotency. let no_change = snapped_pts.iter().zip(targets.iter()).all( |((_, nl, ng), (_, ol, og))| { Geodesic.distance(Point::new(*og, *ol), Point::new(*ng, *nl)) < 0.5 }, ); let mid_after = snapped_pts.iter().find(|(l, _, _)| *l == "mid"); let return_lat = mid_after.map(|m| m.1).unwrap_or(snapped_pts[0].1); let return_lng = mid_after.map(|m| m.2).unwrap_or(snapped_pts[0].2); if no_change { return Ok((return_lat, return_lng)); } // Persist as a "move" so it's undoable. let before_json = serde_json::to_string(&row).map_err(|e| e.to_string())?; let mut new_mid_lat = row.lat; let mut new_mid_lng = row.lng; let mut new_s_lat = row.start_lat; let mut new_s_lng = row.start_lng; let mut new_e_lat = row.end_lat; let mut new_e_lng = row.end_lng; for (label, nlat, nlng) in &snapped_pts { match *label { "start" => { new_s_lat = Some(*nlat); new_s_lng = Some(*nlng); } "mid" => { new_mid_lat = Some(*nlat); new_mid_lng = Some(*nlng); } "end" => { new_e_lat = Some(*nlat); new_e_lng = Some(*nlng); } _ => {} } } let min_lat = min_or_nan(&[new_mid_lat, new_s_lat, new_e_lat]); let max_lat = max_or_nan(&[new_mid_lat, new_s_lat, new_e_lat]); let min_lng = min_or_nan(&[new_mid_lng, new_s_lng, new_e_lng]); let max_lng = max_or_nan(&[new_mid_lng, new_s_lng, new_e_lng]); let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(new_mid_lat) .bind(new_mid_lng) .bind(new_s_lat) .bind(new_s_lng) .bind(new_e_lat) .bind(new_e_lng) .bind(min_lat) .bind(max_lat) .bind(min_lng) .bind(max_lng) .bind(user_id()) .bind(asset_id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; let after_json = serde_json::to_string( &snapped_pts .iter() .map(|(l, la, ln)| (l.to_string(), *la, *ln)) .collect::>(), ) .map_err(|e| e.to_string())?; // Distinct action_type so redo_action can deserialise `after` as the // tuple list snap writes (Vec<(label, lat, lng)>) instead of MoveAfter, // which is what plain drag uses. Undo path is the same shape (`before` // is the full Asset snapshot) and routed via "move" | "snap_single". sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts) VALUES ('snap_single', 'single', ?, ?, ?, ?, datetime('now'))", ) .bind(format!("[{}]", asset_id)) .bind(user_id()) .bind(&before_json) .bind(&after_json) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; apply_scope_after_change(&state.pool).await?; Ok((return_lat, return_lng)) } #[derive(Debug, Serialize)] pub struct BulkSnapResult { pub snapped: usize, pub skipped_far: usize, pub skipped_no_geom: usize, } #[tauri::command] pub async fn snap_assets_in_bbox( min_lat: Option, max_lat: Option, min_lng: Option, max_lng: Option, video_name: Option, max_distance_m: Option, offset_m: Option, centerline_names: Option>, metadata_track: Option>, state: State<'_, AppState>, ) -> Result { let max_d = max_distance_m.unwrap_or(50.0); let offset = offset_m.unwrap_or(0.0).max(0.0); let centerline: std::collections::HashSet = centerline_names.unwrap_or_default().into_iter().collect(); let road_rows: Vec<(i64, String, i64)> = sqlx::query_as( "SELECT id, geojson, COALESCE(oneway, 0) FROM roads WHERE COALESCE(snap_ignored, 0) = 0", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; if road_rows.is_empty() { return Err("no snap-eligible roads loaded — import OSM first or unmute roads".into()); } let roads: Vec<(LineString, i32)> = road_rows .into_iter() .filter_map(|(_, gj, ow)| parse_linestring(&gj).map(|l| (l, ow as i32))) .collect(); // Build the spatial index ONCE for the whole bulk operation. Each asset // vertex then queries a 3×3 grid window instead of scanning every road. let snap_index = SnapIndex::build(&roads, max_d); let assets: Vec = if let Some(vname) = video_name.as_deref() { sqlx::query_as( "SELECT a.id, a.row_id, a.asset_type, a.asset_name, a.video_name, a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng, a.image_path, a.image_path1, a.image_path2, a.image_path3, a.deleted, a.modified, a.merged_into, a.in_scope, a.side, a.link_pair_id, a.link_locked FROM assets a WHERE a.video_name = ? AND a.deleted = 0", ) .bind(vname) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())? } else { let (mn_la, mx_la, mn_ln, mx_ln) = match (min_lat, max_lat, min_lng, max_lng) { (Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d), _ => return Err("provide either video_name or full bbox".into()), }; sqlx::query_as( "SELECT a.id, a.row_id, a.asset_type, a.asset_name, a.video_name, a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng, a.image_path, a.image_path1, a.image_path2, a.image_path3, a.deleted, a.modified, a.merged_into, a.in_scope, a.side, a.link_pair_id, a.link_locked FROM assets a JOIN assets_rtree r ON r.id = a.id WHERE r.max_lat >= ? AND r.min_lat <= ? AND r.max_lng >= ? AND r.min_lng <= ? AND a.deleted = 0", ) .bind(mn_la) .bind(mx_la) .bind(mn_ln) .bind(mx_ln) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())? }; let mut snapped = 0usize; let mut skipped_far = 0usize; let mut skipped_no_geom = 0usize; let mut affected_ids: Vec = vec![]; let mut before_map = serde_json::Map::new(); let mut after_map = serde_json::Map::new(); let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; let track = metadata_track.as_deref(); for asset in assets { let asset_offset = if centerline.contains(&asset.asset_name) { 0.0 } else { offset }; let side = asset.side.as_deref(); // Build the list of vertices to snap. For fixed: just (mid). // For range: (start, mid, end), each independently snapped/offset. let mut targets: Vec<(&'static str, f64, f64)> = vec![]; if asset.asset_type == "range" { if let (Some(la), Some(ln)) = (asset.start_lat, asset.start_lng) { targets.push(("start", la, ln)); } if let (Some(la), Some(ln)) = (asset.lat, asset.lng) { targets.push(("mid", la, ln)); } if let (Some(la), Some(ln)) = (asset.end_lat, asset.end_lng) { targets.push(("end", la, ln)); } } else if let (Some(la), Some(ln)) = (asset.lat, asset.lng) { targets.push(("mid", la, ln)); } if targets.is_empty() { skipped_no_geom += 1; continue; } // Snap each vertex; bail out for the asset if any vertex is too far // (keeps the asset's three vertices in lockstep — partial snap would // produce a kinked polyline). let mut snapped_pts: Vec<(&'static str, f64, f64)> = vec![]; let mut bail = false; let mut too_far = false; for (label, vlat, vlng) in &targets { let cands = snap_index.candidates(*vlng, *vlat); match snap_point_with_segments( cands, side, track, *vlng, *vlat, asset_offset, max_d, ) { Ok((nlat, nlng)) => snapped_pts.push((*label, nlat, nlng)), Err(msg) => { if msg.contains("away") { too_far = true; } else { bail = true; } } } } if bail || snapped_pts.len() != targets.len() { if too_far { skipped_far += 1; } else { skipped_no_geom += 1; } continue; } // Idempotency: if every vertex moved <0.1m, skip. let no_change = snapped_pts.iter().zip(targets.iter()).all( |((_, nl, ng), (_, ol, og))| { Geodesic.distance(Point::new(*og, *ol), Point::new(*ng, *nl)) < 0.5 }, ); if no_change { continue; } before_map.insert( asset.id.to_string(), serde_json::to_value(&asset).unwrap_or(serde_json::Value::Null), ); let mut after_obj = serde_json::Map::new(); let mut new_mid_lat = asset.lat; let mut new_mid_lng = asset.lng; let mut new_s_lat = asset.start_lat; let mut new_s_lng = asset.start_lng; let mut new_e_lat = asset.end_lat; let mut new_e_lng = asset.end_lng; for (label, nlat, nlng) in &snapped_pts { after_obj.insert(format!("{}_lat", label), serde_json::json!(nlat)); after_obj.insert(format!("{}_lng", label), serde_json::json!(nlng)); match *label { "start" => { new_s_lat = Some(*nlat); new_s_lng = Some(*nlng); } "mid" => { new_mid_lat = Some(*nlat); new_mid_lng = Some(*nlng); } "end" => { new_e_lat = Some(*nlat); new_e_lng = Some(*nlng); } _ => {} } } after_map.insert( asset.id.to_string(), serde_json::Value::Object(after_obj), ); affected_ids.push(asset.id); let mn_lat = min_or_nan(&[new_mid_lat, new_s_lat, new_e_lat]); let mx_lat = max_or_nan(&[new_mid_lat, new_s_lat, new_e_lat]); let mn_lng = min_or_nan(&[new_mid_lng, new_s_lng, new_e_lng]); let mx_lng = max_or_nan(&[new_mid_lng, new_s_lng, new_e_lng]); sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(new_mid_lat) .bind(new_mid_lng) .bind(new_s_lat) .bind(new_s_lng) .bind(new_e_lat) .bind(new_e_lng) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(user_id()) .bind(asset.id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; snapped += 1; } if snapped > 0 { let ids_json = serde_json::to_string(&affected_ids).map_err(|e| e.to_string())?; let before_json = serde_json::to_string(&before_map).map_err(|e| e.to_string())?; let after_json = serde_json::to_string(&after_map).map_err(|e| e.to_string())?; sqlx::query( "INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts) VALUES ('bulk_snap', 'bulk', ?, ?, ?, ?, datetime('now'))", ) .bind(&ids_json) .bind(user_id()) .bind(&before_json) .bind(&after_json) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } tx.commit().await.map_err(|e| e.to_string())?; apply_scope_after_change(&state.pool).await?; Ok(BulkSnapResult { snapped, skipped_far, skipped_no_geom, }) } #[tauri::command] pub async fn reset_database(state: State<'_, AppState>) -> Result<(), String> { let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; // Trigger assets_ad clears assets_rtree as rows leave assets. sqlx::query("DELETE FROM assets") .execute(&mut *tx) .await .map_err(|e| e.to_string())?; sqlx::query("DELETE FROM scope_polygons") .execute(&mut *tx) .await .map_err(|e| e.to_string())?; sqlx::query("DELETE FROM action_history") .execute(&mut *tx) .await .map_err(|e| e.to_string())?; sqlx::query("DELETE FROM roads") .execute(&mut *tx) .await .map_err(|e| e.to_string())?; sqlx::query("DELETE FROM video_metadata") .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn save_video_metadata( tracks: std::collections::HashMap>, state: State<'_, AppState>, ) -> Result { let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; sqlx::query("DELETE FROM video_metadata") .execute(&mut *tx) .await .map_err(|e| e.to_string())?; let mut written = 0usize; for (name, track) in &tracks { let json = serde_json::to_string(track).map_err(|e| e.to_string())?; sqlx::query("INSERT INTO video_metadata(video_name, track_json) VALUES (?, ?)") .bind(name) .bind(&json) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; written += 1; } tx.commit().await.map_err(|e| e.to_string())?; Ok(written) } #[tauri::command] pub async fn get_video_metadata( state: State<'_, AppState>, ) -> Result>, String> { let rows: Vec<(String, String)> = sqlx::query_as("SELECT video_name, track_json FROM video_metadata") .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; let mut out: std::collections::HashMap> = std::collections::HashMap::with_capacity(rows.len()); for (name, json) in rows { match serde_json::from_str::>(&json) { Ok(track) => { out.insert(name, track); } Err(_) => continue, } } Ok(out) } #[tauri::command] pub async fn clear_video_metadata(state: State<'_, AppState>) -> Result { let res = sqlx::query("DELETE FROM video_metadata") .execute(&state.pool) .await .map_err(|e| e.to_string())?; Ok(res.rows_affected() as usize) } #[derive(Debug, Serialize)] pub struct DataSourceCounts { pub fixed_assets: i64, pub range_assets: i64, pub osm_roads: i64, pub scope_features: i64, pub video_metadata: i64, } #[tauri::command] pub async fn data_source_counts( state: State<'_, AppState>, ) -> Result { let (fixed,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM assets WHERE asset_type = 'fixed' AND deleted = 0") .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; let (range,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM assets WHERE asset_type = 'range' AND deleted = 0") .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; let (roads,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roads") .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; let (scope,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scope_polygons") .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; let (vmeta,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM video_metadata") .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; Ok(DataSourceCounts { fixed_assets: fixed, range_assets: range, osm_roads: roads, scope_features: scope, video_metadata: vmeta, }) } #[tauri::command] pub async fn clear_assets_by_type( asset_type: String, state: State<'_, AppState>, ) -> Result { if asset_type != "fixed" && asset_type != "range" { return Err("asset_type must be 'fixed' or 'range'".into()); } let res = sqlx::query("DELETE FROM assets WHERE asset_type = ?") .bind(&asset_type) .execute(&state.pool) .await .map_err(|e| e.to_string())?; Ok(res.rows_affected() as usize) } #[tauri::command] pub async fn clear_osm_roads( state: State<'_, AppState>, ) -> Result { let res = sqlx::query("DELETE FROM roads") .execute(&state.pool) .await .map_err(|e| e.to_string())?; Ok(res.rows_affected() as usize) } #[tauri::command] pub async fn clear_scope_polygons( state: State<'_, AppState>, ) -> Result { let res = sqlx::query("DELETE FROM scope_polygons") .execute(&state.pool) .await .map_err(|e| e.to_string())?; // Reset derived in_scope on every asset that didn't have a manual override. sqlx::query("UPDATE assets SET in_scope = NULL WHERE manual_scope IS NULL") .execute(&state.pool) .await .map_err(|e| e.to_string())?; Ok(res.rows_affected() as usize) } #[tauri::command] pub async fn list_recent_actions( limit: i64, state: State<'_, AppState>, ) -> Result, String> { sqlx::query_as::<_, AuditRow>( "SELECT id, action_type, scope, asset_ids, user_id, before, after, ts, undone FROM action_history ORDER BY id DESC LIMIT ?", ) .bind(limit) .fetch_all(&state.pool) .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn redo_action(action_id: i64, state: State<'_, AppState>) -> Result<(), String> { let row: AuditRow = sqlx::query_as( "SELECT id, action_type, scope, asset_ids, user_id, before, after, ts, undone FROM action_history WHERE id = ?", ) .bind(action_id) .fetch_one(&state.pool) .await .map_err(|e| e.to_string())?; if row.undone == 0 { return Err("action is not in an undone state".to_string()); } let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; match row.action_type.as_str() { "move" => { let after_str = row.after.as_deref().unwrap_or(""); let mv: MoveAfter = serde_json::from_str(after_str).map_err(|e| e.to_string())?; let ids: Vec = serde_json::from_str(&row.asset_ids) .map_err(|e| e.to_string())?; let id = *ids.first().ok_or("missing asset id")?; let asset: Asset = sqlx::query_as( "SELECT id, row_id, asset_type, asset_name, video_name, lat, lng, start_lat, start_lng, end_lat, end_lng, image_path, image_path1, image_path2, image_path3, deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked FROM assets WHERE id = ?", ) .bind(id) .fetch_one(&mut *tx) .await .map_err(|e| e.to_string())?; let (new_lat, new_lng, new_slat, new_slng, new_elat, new_elng) = match mv.vertex.as_str() { "fixed" | "mid" => ( Some(mv.lat), Some(mv.lng), asset.start_lat, asset.start_lng, asset.end_lat, asset.end_lng, ), "start" => ( asset.lat, asset.lng, Some(mv.lat), Some(mv.lng), asset.end_lat, asset.end_lng, ), "end" => ( asset.lat, asset.lng, asset.start_lat, asset.start_lng, Some(mv.lat), Some(mv.lng), ), other => return Err(format!("invalid vertex: {}", other)), }; let min_lat = min_or_nan(&[new_lat, new_slat, new_elat]); let max_lat = max_or_nan(&[new_lat, new_slat, new_elat]); let min_lng = min_or_nan(&[new_lng, new_slng, new_elng]); let max_lng = max_or_nan(&[new_lng, new_slng, new_elng]); sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(new_lat) .bind(new_lng) .bind(new_slat) .bind(new_slng) .bind(new_elat) .bind(new_elng) .bind(min_lat) .bind(max_lat) .bind(min_lng) .bind(max_lng) .bind(user_id()) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } "snap_single" => { // snap_to_road wrote `after` as Vec<(label, lat, lng)>; replay // each labelled vertex onto its slot. 1 entry for fixed (mid), // up to 3 for range (start/mid/end). let after_str = row.after.as_deref().unwrap_or("[]"); let pts: Vec<(String, f64, f64)> = serde_json::from_str(after_str).map_err(|e| e.to_string())?; let ids: Vec = serde_json::from_str(&row.asset_ids) .map_err(|e| e.to_string())?; let id = *ids.first().ok_or("missing asset id")?; let asset: Asset = sqlx::query_as( "SELECT id, row_id, asset_type, asset_name, video_name, lat, lng, start_lat, start_lng, end_lat, end_lng, image_path, image_path1, image_path2, image_path3, deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked FROM assets WHERE id = ?", ) .bind(id) .fetch_one(&mut *tx) .await .map_err(|e| e.to_string())?; let mut new_lat = asset.lat; let mut new_lng = asset.lng; let mut new_slat = asset.start_lat; let mut new_slng = asset.start_lng; let mut new_elat = asset.end_lat; let mut new_elng = asset.end_lng; for (label, lat, lng) in &pts { match label.as_str() { "fixed" | "mid" => { new_lat = Some(*lat); new_lng = Some(*lng); } "start" => { new_slat = Some(*lat); new_slng = Some(*lng); } "end" => { new_elat = Some(*lat); new_elng = Some(*lng); } _ => {} } } let min_lat = min_or_nan(&[new_lat, new_slat, new_elat]); let max_lat = max_or_nan(&[new_lat, new_slat, new_elat]); let min_lng = min_or_nan(&[new_lng, new_slng, new_elng]); let max_lng = max_or_nan(&[new_lng, new_slng, new_elng]); sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(new_lat) .bind(new_lng) .bind(new_slat) .bind(new_slng) .bind(new_elat) .bind(new_elng) .bind(min_lat) .bind(max_lat) .bind(min_lng) .bind(max_lng) .bind(user_id()) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } "delete" | "delete_by_video" => { let ids: Vec = serde_json::from_str(&row.asset_ids).map_err(|e| e.to_string())?; for id in ids { sqlx::query( "UPDATE assets SET deleted = 1, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(user_id()) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "set_scope" => { let after_map: serde_json::Map = serde_json::from_str(row.after.as_deref().unwrap_or("{}")) .map_err(|e| e.to_string())?; for (id_str, val) in after_map { let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?; let in_scope = val.get("in_scope").and_then(|v| v.as_i64()); let manual = val.get("manual_scope").and_then(|v| v.as_i64()); sqlx::query( "UPDATE assets SET in_scope = ?, manual_scope = ? WHERE id = ?", ) .bind(in_scope) .bind(manual) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "rename" => { let after_val: serde_json::Value = serde_json::from_str(row.after.as_deref().unwrap_or("{}")) .map_err(|e| e.to_string())?; let new_name = after_val .get("asset_name") .and_then(|v| v.as_str()) .unwrap_or(""); let ids: Vec = serde_json::from_str(&row.asset_ids) .map_err(|e| e.to_string())?; for id in ids { sqlx::query("UPDATE assets SET asset_name = ? WHERE id = ?") .bind(new_name) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "change_side" => { let after_val: serde_json::Value = serde_json::from_str(row.after.as_deref().unwrap_or("{}")) .map_err(|e| e.to_string())?; let new_side = after_val .get("side") .and_then(|v| v.as_str()) .map(String::from); let ids: Vec = serde_json::from_str(&row.asset_ids) .map_err(|e| e.to_string())?; for id in ids { sqlx::query("UPDATE assets SET side = ? WHERE id = ?") .bind(&new_side) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "link" => { let after_map: serde_json::Map = serde_json::from_str(row.after.as_deref().unwrap_or("{}")) .map_err(|e| e.to_string())?; for (id_str, val) in after_map { let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?; let pair = val.get("link_pair_id").and_then(|v| v.as_i64()); let locked = val .get("link_locked") .and_then(|v| v.as_i64()) .unwrap_or(0); sqlx::query( "UPDATE assets SET link_pair_id = ?, link_locked = ? WHERE id = ?", ) .bind(pair) .bind(locked) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "restore" => { let ids: Vec = serde_json::from_str(&row.asset_ids) .map_err(|e| e.to_string())?; for id in ids { sqlx::query("UPDATE assets SET deleted = 0 WHERE id = ?") .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "merge" => { let ids: Vec = serde_json::from_str(&row.asset_ids).map_err(|e| e.to_string())?; if ids.len() != 2 { return Err("malformed merge audit row".to_string()); } // Reapply the merged primary geometry from the `after` snapshot. // Older audit rows (from before this fix) may not have `after` — // fall back to re-deleting the secondary only. let after_str = row.after.as_deref().unwrap_or(""); if let Ok(after) = serde_json::from_str::(after_str) { let getn = |k: &str| after.get(k).and_then(|v| v.as_f64()); if let ( Some(lat), Some(lng), Some(s_lat), Some(s_lng), Some(e_lat), Some(e_lng), Some(mn_lat), Some(mx_lat), Some(mn_lng), Some(mx_lng), ) = ( getn("lat"), getn("lng"), getn("start_lat"), getn("start_lng"), getn("end_lat"), getn("end_lng"), getn("min_lat"), getn("max_lat"), getn("min_lng"), getn("max_lng"), ) { sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(lat) .bind(lng) .bind(s_lat) .bind(s_lng) .bind(e_lat) .bind(e_lng) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(user_id()) .bind(ids[0]) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } sqlx::query( "UPDATE assets SET merged_into = ?, deleted = 1, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(ids[0]) .bind(user_id()) .bind(ids[1]) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } "bulk_snap" => { let after_map: serde_json::Map = serde_json::from_str(row.after.as_deref().unwrap_or("{}")) .map_err(|e| e.to_string())?; for (id_str, val) in after_map { let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?; // bulk_snap stores keys: mid_lat/mid_lng (always), and // start_lat/start_lng + end_lat/end_lng for range assets. Read // the current row so we can leave any vertex untouched if the // snap didn't supply that label. let cur: Asset = sqlx::query_as( "SELECT id, row_id, asset_type, asset_name, video_name, lat, lng, start_lat, start_lng, end_lat, end_lng, image_path, image_path1, image_path2, image_path3, deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked FROM assets WHERE id = ?", ) .bind(id) .fetch_one(&mut *tx) .await .map_err(|e| e.to_string())?; let read = |k: &str| val.get(k).and_then(|v| v.as_f64()); let new_lat = read("mid_lat").or(cur.lat); let new_lng = read("mid_lng").or(cur.lng); let new_slat = read("start_lat").or(cur.start_lat); let new_slng = read("start_lng").or(cur.start_lng); let new_elat = read("end_lat").or(cur.end_lat); let new_elng = read("end_lng").or(cur.end_lng); let mn_lat = min_or_nan(&[new_lat, new_slat, new_elat]); let mx_lat = max_or_nan(&[new_lat, new_slat, new_elat]); let mn_lng = min_or_nan(&[new_lng, new_slng, new_elng]); let mx_lng = max_or_nan(&[new_lng, new_slng, new_elng]); sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(new_lat) .bind(new_lng) .bind(new_slat) .bind(new_slng) .bind(new_elat) .bind(new_elng) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(user_id()) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } "bulk_translate" => { // Re-apply the post-translate state captured at action time. let after_map: serde_json::Map = serde_json::from_str(row.after.as_deref().unwrap_or("{}")) .map_err(|e| e.to_string())?; for (id_str, val) in after_map { let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?; let lat = val.get("lat").and_then(|v| v.as_f64()); let lng = val.get("lng").and_then(|v| v.as_f64()); let slat = val.get("start_lat").and_then(|v| v.as_f64()); let slng = val.get("start_lng").and_then(|v| v.as_f64()); let elat = val.get("end_lat").and_then(|v| v.as_f64()); let elng = val.get("end_lng").and_then(|v| v.as_f64()); let lats: Vec = [lat, slat, elat].into_iter().flatten().collect(); let lngs: Vec = [lng, slng, elng].into_iter().flatten().collect(); if lats.is_empty() || lngs.is_empty() { continue; } let mn_lat = lats.iter().cloned().fold(f64::INFINITY, f64::min); let mx_lat = lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max); let mn_lng = lngs.iter().cloned().fold(f64::INFINITY, f64::min); let mx_lng = lngs.iter().cloned().fold(f64::NEG_INFINITY, f64::max); sqlx::query( "UPDATE assets SET lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?", ) .bind(lat) .bind(lng) .bind(slat) .bind(slng) .bind(elat) .bind(elng) .bind(mn_lat) .bind(mx_lat) .bind(mn_lng) .bind(mx_lng) .bind(user_id()) .bind(id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } } other => return Err(format!("unknown action_type: {}", other)), } sqlx::query("UPDATE action_history SET undone = 0 WHERE id = ?") .bind(action_id) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; apply_scope_after_change(&state.pool).await?; Ok(()) } #[tauri::command] pub async fn list_assets(state: State<'_, AppState>) -> Result, String> { sqlx::query_as::<_, Asset>( "SELECT id, row_id, asset_type, asset_name, video_name, lat, lng, start_lat, start_lng, end_lat, end_lng, image_path, image_path1, image_path2, image_path3, deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked FROM assets WHERE deleted = 0", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string()) } // Bbox-overlap query via R-tree. // NOTE: does not handle antimeridian crossings — fine for current Qatar data. #[tauri::command] pub async fn get_assets_in_bbox( min_lat: f64, max_lat: f64, min_lng: f64, max_lng: f64, include_deleted: Option, state: State<'_, AppState>, ) -> Result, String> { let include = include_deleted.unwrap_or(false); let sql = if include { "SELECT a.id, a.row_id, a.asset_type, a.asset_name, a.video_name, a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng, a.image_path, a.image_path1, a.image_path2, a.image_path3, a.deleted, a.modified, a.merged_into, a.in_scope, a.side, a.link_pair_id, a.link_locked FROM assets a JOIN assets_rtree r ON r.id = a.id WHERE r.max_lat >= ? AND r.min_lat <= ? AND r.max_lng >= ? AND r.min_lng <= ?" } else { "SELECT a.id, a.row_id, a.asset_type, a.asset_name, a.video_name, a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng, a.image_path, a.image_path1, a.image_path2, a.image_path3, a.deleted, a.modified, a.merged_into, a.in_scope, a.side, a.link_pair_id, a.link_locked FROM assets a JOIN assets_rtree r ON r.id = a.id WHERE r.max_lat >= ? AND r.min_lat <= ? AND r.max_lng >= ? AND r.min_lng <= ? AND a.deleted = 0" }; sqlx::query_as::<_, Asset>(sql) .bind(min_lat) .bind(max_lat) .bind(min_lng) .bind(max_lng) .fetch_all(&state.pool) .await .map_err(|e| e.to_string()) } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct AssetCoord { pub id: i64, pub lat: Option, pub lng: Option, } #[tauri::command] pub async fn assets_coords_for_ids( ids: Vec, state: State<'_, AppState>, ) -> Result, String> { let mut out: Vec = Vec::with_capacity(ids.len()); for chunk in ids.chunks(500) { let placeholders = vec!["?"; chunk.len()].join(","); let sql = format!( "SELECT id, lat, lng FROM assets WHERE id IN ({})", placeholders ); let mut q = sqlx::query_as::<_, AssetCoord>(&sql); for id in chunk { q = q.bind(id); } let part = q .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; out.extend(part); } Ok(out) } // ---------------------------------------------------------------------------- // Quality report — surfaces the most common data-issue categories so users // can audit a dataset proactively instead of stumbling on problems while // reviewing. MVP: moved-far, impossible row_id jumps, missing side. // ---------------------------------------------------------------------------- #[derive(Debug, Serialize)] pub struct QualityReport { /// Modified rows whose current position is > moved_threshold_m from /// the original (import-time) position. pub moved_far: Vec, /// Rows whose current position is > metadata_threshold_m from the /// nearest segment of their video's metadata polyline. Only checked /// for videos whose metadata has been loaded — otherwise skipped. pub far_from_metadata: Vec, /// Rows with `side IS NULL`. pub missing_side: Vec, pub moved_far_total: usize, pub far_from_metadata_total: usize, pub missing_side_total: usize, } // Cheap haversine in metres — fine at the thresholds we're checking. fn hav_m(a_lat: f64, a_lng: f64, b_lat: f64, b_lng: f64) -> f64 { let r = 6_371_000.0_f64; let p1 = a_lat.to_radians(); let p2 = b_lat.to_radians(); let dp = (b_lat - a_lat).to_radians(); let dl = (b_lng - a_lng).to_radians(); let s = (dp / 2.0).sin().powi(2) + p1.cos() * p2.cos() * (dl / 2.0).sin().powi(2); 2.0 * r * s.sqrt().asin() } #[tauri::command] pub async fn quality_report( moved_threshold_m: Option, metadata_threshold_m: Option, metadata: Option>>, cap: Option, state: State<'_, AppState>, ) -> Result { // Defaults — conservative so you only see real signal. The // outlier-from-neighbours check was dropped: without a real reference // (which the metadata polyline already provides) the only honest // signal is far-from-metadata; gap- or chord-based heuristics produce // thousands of false positives on multimodal / curved / cross-side // data and aren't worth the noise. let moved_t = moved_threshold_m.unwrap_or(50.0); let metadata_t = metadata_threshold_m.unwrap_or(30.0); let cap = cap.unwrap_or(500); let metadata = metadata.unwrap_or_default(); // --- Moved far from import --- // modified=1 means the user has touched this row. We compare current vs // orig coords; rows without orig (pre-migration data) are skipped. let moved_rows: Vec<(i64, Option, Option, Option, Option)> = sqlx::query_as( "SELECT id, lat, lng, orig_lat, orig_lng FROM assets WHERE deleted = 0 AND modified = 1 AND lat IS NOT NULL AND lng IS NOT NULL AND orig_lat IS NOT NULL AND orig_lng IS NOT NULL", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; let mut moved_far: Vec = Vec::new(); let mut moved_far_total: usize = 0; for (id, lat, lng, ol, og) in moved_rows { if let (Some(la), Some(ln), Some(ola), Some(olg)) = (lat, lng, ol, og) { if hav_m(la, ln, ola, olg) >= moved_t { moved_far_total += 1; if moved_far.len() < cap { moved_far.push(id); } } } } // --- Missing side --- let missing_rows: Vec<(i64,)> = sqlx::query_as( "SELECT id FROM assets WHERE deleted = 0 AND side IS NULL", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; let missing_side_total = missing_rows.len(); let missing_side: Vec = missing_rows .into_iter() .take(cap) .map(|(id,)| id) .collect(); // --- Pull every live asset once for the next two checks. --- let video_rows: Vec<(i64, String, String, Option, Option)> = sqlx::query_as( "SELECT id, video_name, row_id, lat, lng FROM assets WHERE deleted = 0 AND lat IS NOT NULL AND lng IS NOT NULL", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; use std::collections::HashMap; let mut by_video: HashMap> = HashMap::new(); for (id, video, row_id, lat, lng) in video_rows { if let (Some(la), Some(ln)) = (lat, lng) { by_video.entry(video).or_default().push((id, row_id, la, ln)); } } // --- Far from metadata polyline --- // Per-asset min point-to-segment distance to the asset's video's // metadata track. `metadata` arrives as `{video_name: [[lng, lat], ...]}`. // Skipped silently when the asset's video has no metadata loaded. let mut far_from_metadata: Vec = Vec::new(); let mut far_from_metadata_total: usize = 0; for (video, rows) in &by_video { let Some(track) = metadata.get(video) else { continue; }; if track.len() < 2 { continue; } for (id, _row_id, lat, lng) in rows { let mut min_d = f64::INFINITY; for w in track.windows(2) { let d = point_segment_distance_m( *lng, *lat, w[0][0], w[0][1], w[1][0], w[1][1], ); if d < min_d { min_d = d; if min_d < 1.0 { break; } } } if min_d.is_finite() && min_d >= metadata_t { far_from_metadata_total += 1; if far_from_metadata.len() < cap { far_from_metadata.push(*id); } } } } // by_video was used only by the dropped outlier check; release it. drop(by_video); Ok(QualityReport { moved_far, far_from_metadata, missing_side, moved_far_total, far_from_metadata_total, missing_side_total, }) } #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct AssetBbox { pub asset_id: i64, pub slot: String, pub x1: f64, pub y1: f64, pub x2: f64, pub y2: f64, // The class label is the asset's name. Reported here so the frontend // doesn't have to plumb assets[asset_id].asset_name separately, but the // source of truth is `assets.asset_name` — rename via rename_assets. pub class: Option, } fn normalize_slot(slot: &str) -> Result<&'static str, String> { match slot { "fixed" => Ok("fixed"), "start" => Ok("start"), "mid" => Ok("mid"), "end" => Ok("end"), other => Err(format!("invalid bbox slot: {other}")), } } #[tauri::command] pub async fn get_asset_bboxes( asset_id: i64, state: State<'_, AppState>, ) -> Result, String> { sqlx::query_as::<_, AssetBbox>( "SELECT b.asset_id, b.slot, b.x1, b.y1, b.x2, b.y2, a.asset_name AS class FROM asset_bboxes b JOIN assets a ON a.id = b.asset_id WHERE b.asset_id = ? ORDER BY b.slot", ) .bind(asset_id) .fetch_all(&state.pool) .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn set_asset_bbox( asset_id: i64, slot: String, x1: f64, y1: f64, x2: f64, y2: f64, state: State<'_, AppState>, ) -> Result<(), String> { let slot = normalize_slot(&slot)?; // Normalize so x1 <= x2 and y1 <= y2 so render math doesn't have to. let (nx1, nx2) = if x1 <= x2 { (x1, x2) } else { (x2, x1) }; let (ny1, ny2) = if y1 <= y2 { (y1, y2) } else { (y2, y1) }; if (nx2 - nx1).abs() < 1.0 || (ny2 - ny1).abs() < 1.0 { return Err("bbox too small (need ≥ 1 px in each axis)".into()); } sqlx::query( "INSERT INTO asset_bboxes(asset_id, slot, x1, y1, x2, y2) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(asset_id, slot) DO UPDATE SET x1 = excluded.x1, y1 = excluded.y1, x2 = excluded.x2, y2 = excluded.y2", ) .bind(asset_id) .bind(slot) .bind(nx1) .bind(ny1) .bind(nx2) .bind(ny2) .execute(&state.pool) .await .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn clear_asset_bbox( asset_id: i64, slot: String, state: State<'_, AppState>, ) -> Result<(), String> { let slot = normalize_slot(&slot)?; sqlx::query("DELETE FROM asset_bboxes WHERE asset_id = ? AND slot = ?") .bind(asset_id) .bind(slot) .execute(&state.pool) .await .map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn export_bboxes_to_json( path: String, state: State<'_, AppState>, ) -> Result { // Sidecar shape: one entry per (row_id, slot). The original JSON's bbox_* // keys stay shallow — we emit a flat `slot` field so consumers don't have // to know the asset_type ahead of time. Class is asset-level so every // entry for the same row_id carries the same value. let rows: Vec<(String, String, String, f64, f64, f64, f64, Option)> = sqlx::query_as( "SELECT a.row_id, a.video_name, b.slot, b.x1, b.y1, b.x2, b.y2, a.asset_name FROM asset_bboxes b JOIN assets a ON a.id = b.asset_id ORDER BY a.row_id, b.slot", ) .fetch_all(&state.pool) .await .map_err(|e| e.to_string())?; let entries: Vec = rows .into_iter() .map(|(row_id, video_name, slot, x1, y1, x2, y2, class)| { serde_json::json!({ "row_id": row_id, "video_name": video_name, "slot": slot, "bbox_x1": x1, "bbox_y1": y1, "bbox_x2": x2, "bbox_y2": y2, "class": class, }) }) .collect(); let n = entries.len(); let out = serde_json::json!({ "bboxes": entries }); let body = serde_json::to_vec_pretty(&out).map_err(|e| e.to_string())?; tokio::fs::write(&path, body).await.map_err(|e| e.to_string())?; Ok(n) }