7106 lines
254 KiB
Rust
7106 lines
254 KiB
Rust
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<String>,
|
||
#[serde(default)]
|
||
deleted: bool,
|
||
#[serde(default)]
|
||
side: Option<String>,
|
||
}
|
||
|
||
#[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<f64, String> {
|
||
match self {
|
||
NumOrStr::Num(v) => Ok(*v),
|
||
NumOrStr::Str(s) => s
|
||
.parse::<f64>()
|
||
.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<NumOrStr>,
|
||
#[serde(default)]
|
||
coord_lng: Option<NumOrStr>,
|
||
start_coord_lat: NumOrStr,
|
||
start_coord_lng: NumOrStr,
|
||
end_coord_lat: NumOrStr,
|
||
end_coord_lng: NumOrStr,
|
||
image_path1: Option<String>,
|
||
image_path2: Option<String>,
|
||
image_path3: Option<String>,
|
||
#[serde(default)]
|
||
deleted: bool,
|
||
#[serde(default)]
|
||
side: Option<String>,
|
||
}
|
||
|
||
fn opt_num_or_str_to_f64(v: &Option<NumOrStr>) -> Option<f64> {
|
||
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::<f64>().ok().filter(|x| x.is_finite())
|
||
}
|
||
}
|
||
}
|
||
|
||
use std::sync::{OnceLock, RwLock};
|
||
static CURRENT_USER: OnceLock<RwLock<String>> = 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<Vec<User>, 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<i64, String> {
|
||
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<String>,
|
||
pub after: Option<String>,
|
||
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<f64>,
|
||
pub lng: Option<f64>,
|
||
pub start_lat: Option<f64>,
|
||
pub start_lng: Option<f64>,
|
||
pub end_lat: Option<f64>,
|
||
pub end_lng: Option<f64>,
|
||
pub image_path: Option<String>,
|
||
pub image_path1: Option<String>,
|
||
pub image_path2: Option<String>,
|
||
pub image_path3: Option<String>,
|
||
pub deleted: i64,
|
||
pub modified: i64,
|
||
pub merged_into: Option<i64>,
|
||
pub in_scope: Option<i64>,
|
||
pub side: Option<String>,
|
||
pub link_pair_id: Option<i64>,
|
||
pub link_locked: Option<i64>,
|
||
}
|
||
|
||
fn normalize_side(s: &str) -> Option<String> {
|
||
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<f64> {
|
||
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::<f64>().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<i64> =
|
||
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<usize, String> {
|
||
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::Value> =
|
||
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<usize, String> {
|
||
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
|
||
let raws: Vec<serde_json::Value> =
|
||
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<AutoImportResult, String> {
|
||
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<serde_json::Value> = match raw {
|
||
serde_json::Value::Array(a) => a,
|
||
serde_json::Value::Object(map) => {
|
||
let mut out: Vec<serde_json::Value> = 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::<f64>().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<String>,
|
||
pub geojson: String,
|
||
pub geom_type: String,
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn import_kml_scope(
|
||
path: String,
|
||
state: State<'_, AppState>,
|
||
) -> Result<usize, String> {
|
||
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<std::collections::HashMap<String, Vec<[f64; 2]>>, String> {
|
||
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
|
||
let parsed: serde_json::Map<String, serde_json::Value> =
|
||
serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON: {}", e))?;
|
||
let mut out: std::collections::HashMap<String, Vec<[f64; 2]>> =
|
||
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<Vec<ScopePolygonOut>, String> {
|
||
let rows: Vec<(i64, Option<String>, 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<f64> = [new_lat, new_slat, new_elat].into_iter().flatten().collect();
|
||
let lngs: Vec<f64> = [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<TranslateCorrection>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<usize, String> {
|
||
if corrections.is_empty() {
|
||
return Ok(0);
|
||
}
|
||
let ids: Vec<i64> = corrections.iter().map(|c| c.id).collect();
|
||
// SQLite caps bind params at 999; chunk the prefetch SELECT.
|
||
let mut rows: Vec<Asset> = Vec::with_capacity(ids.len());
|
||
for chunk in ids.chunks(500) {
|
||
let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().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<Asset> = q
|
||
.fetch_all(&state.pool)
|
||
.await
|
||
.map_err(|e| e.to_string())?;
|
||
rows.extend(part);
|
||
}
|
||
use std::collections::HashMap;
|
||
let by_id: HashMap<i64, &Asset> = 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<f64> = [new_lat, new_slat, new_elat]
|
||
.into_iter()
|
||
.flatten()
|
||
.collect();
|
||
let lngs: Vec<f64> = [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<i64>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<usize, String> {
|
||
if ids.is_empty() {
|
||
return Ok(0);
|
||
}
|
||
// SQLite caps bind params at 999; chunk both SELECTs.
|
||
let mut rows: Vec<Asset> = Vec::with_capacity(ids.len());
|
||
for chunk in ids.chunks(500) {
|
||
let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().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<Asset> = 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<f64>,
|
||
Option<f64>,
|
||
Option<f64>,
|
||
Option<f64>,
|
||
Option<f64>,
|
||
Option<f64>,
|
||
String,
|
||
);
|
||
let mut origs: Vec<OrigRow> = Vec::with_capacity(ids.len());
|
||
for chunk in ids.chunks(500) {
|
||
let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().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<i64, (Option<f64>, Option<f64>, Option<f64>, Option<f64>, Option<f64>, Option<f64>, 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<f64> = [new_lat, new_slat, new_elat]
|
||
.into_iter()
|
||
.flatten()
|
||
.collect();
|
||
let lngs: Vec<f64> = [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<usize, String> {
|
||
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<i64> = 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<i64> = 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<String, serde_json::Value> =
|
||
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<String, serde_json::Value> =
|
||
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<String, serde_json::Value> =
|
||
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<String, serde_json::Value> =
|
||
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<String, serde_json::Value> =
|
||
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<f64> = [lat, slat, elat].into_iter().flatten().collect();
|
||
let lngs: Vec<f64> = [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<String, serde_json::Value> =
|
||
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<f64> = [lat, slat, elat].into_iter().flatten().collect();
|
||
let lngs: Vec<f64> = [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<i64> = 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<i64> = 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<String, serde_json::Value> =
|
||
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>]) -> f64 {
|
||
vals.iter()
|
||
.filter_map(|v| *v)
|
||
.fold(f64::INFINITY, f64::min)
|
||
}
|
||
|
||
fn max_or_nan(vals: &[Option<f64>]) -> f64 {
|
||
vals.iter()
|
||
.filter_map(|v| *v)
|
||
.fold(f64::NEG_INFINITY, f64::max)
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct GeoJsonFeatureCollection {
|
||
features: Vec<GeoJsonFeature>,
|
||
}
|
||
|
||
#[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<String>,
|
||
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<serde_json::Map<String, serde_json::Value>, 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<i64>, Option<i64>)>(&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<i64>,
|
||
in_scope: bool,
|
||
state: State<'_, AppState>,
|
||
) -> Result<usize, String> {
|
||
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<i64>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<usize, String> {
|
||
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<usize, String> {
|
||
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<i64> = 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<i64>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<usize, String> {
|
||
if ids.is_empty() {
|
||
return Ok(0);
|
||
}
|
||
// SQLite caps bind params at 999; chunk into batches.
|
||
let mut assets: Vec<Asset> = Vec::with_capacity(ids.len());
|
||
for chunk in ids.chunks(500) {
|
||
let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().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::<Vec<_>>().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<i64> = 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<usize, String> {
|
||
let assets: Vec<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 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||
s.push_str("<kml xmlns=\"http://www.opengis.net/kml/2.2\"><Document>\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!(
|
||
"<Placemark><name>{}</name><description>{}</description>\
|
||
<Point><coordinates>{},{},0</coordinates></Point></Placemark>\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::<Vec<_>>()
|
||
.join(" ");
|
||
s.push_str(&format!(
|
||
"<Placemark><name>{}</name><description>{}</description>\
|
||
<LineString><coordinates>{}</coordinates></LineString></Placemark>\n",
|
||
xml_escape(&name),
|
||
xml_escape(&desc),
|
||
coord_str
|
||
));
|
||
}
|
||
}
|
||
}
|
||
s.push_str("</Document></kml>\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<serde_json::Value> = vec![];
|
||
let mut range_arr: Vec<serde_json::Value> = 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<f64>| o.map(|v| v.to_string()).unwrap_or_default();
|
||
let i = |o: Option<i64>| 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<Vec<String>, 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<String> = 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<serde_json::Map<String, serde_json::Value>, 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<i64>, Option<i64>)>(&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<AutoLinkResult, String> {
|
||
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<Asset> = 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<String, Vec<&Asset>> = 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<i64> = 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<i64>,)>(&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<i64>)> =
|
||
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<i64>,) =
|
||
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<i64>,
|
||
side: String,
|
||
state: State<'_, AppState>,
|
||
) -> Result<usize, String> {
|
||
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<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, 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<i64>,
|
||
asset_name: String,
|
||
state: State<'_, AppState>,
|
||
) -> Result<usize, String> {
|
||
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<String>,
|
||
pub lat: Option<f64>,
|
||
pub lng: Option<f64>,
|
||
pub asset_type: String,
|
||
pub start_lat: Option<f64>,
|
||
pub start_lng: Option<f64>,
|
||
pub end_lat: Option<f64>,
|
||
pub end_lng: Option<f64>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
pub struct DuplicateCluster {
|
||
pub id: usize,
|
||
pub asset_name: String,
|
||
pub members: Vec<DuplicateMember>,
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn find_duplicate_clusters(
|
||
eps_m: f64,
|
||
min_lat: Option<f64>,
|
||
max_lat: Option<f64>,
|
||
min_lng: Option<f64>,
|
||
max_lng: Option<f64>,
|
||
cross_video_only: Option<bool>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<Vec<DuplicateCluster>, 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<Asset> = 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<DuplicateCluster> = 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<usize> = (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<usize, Vec<usize>> = 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<DuplicateMember> = 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<Vec<String>, 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<String>,
|
||
pub completed_by: Option<String>,
|
||
}
|
||
|
||
/// 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<Vec<VideoEntry>, 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<String> = 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<String>, Option<String>)> = 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<String>, Option<String>),
|
||
> = std::collections::HashMap::new();
|
||
for (n, c, at, by) in status_rows {
|
||
status.insert(n, (c, at, by));
|
||
}
|
||
|
||
let mut out: Vec<VideoEntry> = 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<i64>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<usize, String> {
|
||
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<Vec<String>, 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<Vec<RoadOut>, String> {
|
||
let rows: Vec<(i64, Option<String>, 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<Vec<[f64; 2]>, 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<Vec<String>>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<usize, String> {
|
||
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<std::collections::HashSet<String>> =
|
||
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<MergeRoadsResult, String> {
|
||
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<String>, 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<f64>, i64, Option<String>, 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<f64>) -> (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<usize>> = 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<usize> = 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<usize> = comp.iter().copied().collect();
|
||
let mut deg: HashMap<(i64, i64), Vec<usize>> = 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<Coord<f64>>> = vec![];
|
||
let mut current_node = start_node;
|
||
let mut used: HashSet<usize> = 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<Coord<f64>> = 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<Coord<f64>> = 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<i64, usize> = 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<i64>,
|
||
snap_meters: Option<f64>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<MergeRoadsResult, String> {
|
||
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<f64>,
|
||
i64,
|
||
Option<String>,
|
||
Option<String>,
|
||
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<String>, Option<String>, 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<usize>> = HashMap::new();
|
||
let mut endpoint_cluster: Vec<usize> = 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<usize> = 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<f64>) -> (i64, i64) {
|
||
((c.x * 1e5).round() as i64, (c.y * 1e5).round() as i64)
|
||
}
|
||
let mut node_to_ways: HashMap<(i64, i64), Vec<usize>> = 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<usize> = 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<usize> = comp.iter().copied().collect();
|
||
let mut deg: HashMap<(i64, i64), Vec<usize>> = 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<Coord<f64>>> = vec![];
|
||
let mut current_node = start_node;
|
||
let mut used: HashSet<usize> = 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<Coord<f64>> = 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<Coord<f64>> = 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<String, usize> = 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<i64, usize> = 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<usize, String> {
|
||
let rows: Vec<(i64, Option<String>, String, i64, Option<String>)> = 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<serde_json::Value> = 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<f64>,
|
||
path: String,
|
||
overpass_url: Option<String>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<GenerateOsmResult, String> {
|
||
// 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<serde_json::Value> = 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<serde_json::Value>,
|
||
ref_points: &[(f64, f64)],
|
||
threshold_m: f64,
|
||
) -> Vec<serde_json::Value> {
|
||
use std::collections::HashMap;
|
||
let cell_deg = (threshold_m / 111_320.0).max(1e-7);
|
||
let mut grid: HashMap<(i64, i64), Vec<usize>> = 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<serde_json::Value> = 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<Option<BboxOut>, 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<f64>,
|
||
Option<f64>,
|
||
Option<f64>,
|
||
Option<f64>,
|
||
)> = 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<String>,
|
||
) -> Result<usize, String> {
|
||
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=<query>`.
|
||
// 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<serde_json::Value> = 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<bool, String> {
|
||
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<i64, String> {
|
||
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<String>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<usize, String> {
|
||
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<String>,
|
||
pub n: i64,
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn list_road_classes(
|
||
state: State<'_, AppState>,
|
||
) -> Result<Vec<RoadClassCount>, 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<String>,
|
||
coords: Vec<[f64; 2]>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<i64, 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,
|
||
});
|
||
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<usize>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<usize, String> {
|
||
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<usize> = 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<usize>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<Vec<i64>, String> {
|
||
if indices.len() < 2 {
|
||
return Err("need at least 2 marked vertices to define a section".into());
|
||
}
|
||
let row: (Option<String>, String, i64, Option<String>, 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<i64> = 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<i64>,
|
||
// 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<RoadSnapshot>,
|
||
}
|
||
|
||
/// 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<LassoRoadDeleteResult, String> {
|
||
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>, String, i64, Option<String>, 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<f64> {
|
||
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<i64> = Vec::new();
|
||
let mut before_snapshots: Vec<RoadSnapshot> = 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<bool> = 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<[f64; 2]>> = 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<Vec<[f64; 2]>>, 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<f64> = 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<f64> = 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::<Vec<_>>(),
|
||
);
|
||
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<String>,
|
||
pub geojson: String,
|
||
pub oneway: i64,
|
||
pub highway: Option<String>,
|
||
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<i64>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<Vec<RoadSnapshot>, String> {
|
||
if ids.is_empty() {
|
||
return Ok(vec![]);
|
||
}
|
||
let mut out: Vec<RoadSnapshot> = 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>, String, i64, Option<String>, 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<RoadSnapshot>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<usize, String> {
|
||
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<LineString<f64>> {
|
||
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<f64>, Coord<f64>, i32)>,
|
||
grid: std::collections::HashMap<(i64, i64), Vec<usize>>,
|
||
cell_deg: f64,
|
||
}
|
||
|
||
impl SnapIndex {
|
||
fn build(roads: &[(LineString<f64>, 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<f64>, Coord<f64>, i32)> = Vec::new();
|
||
let mut grid: std::collections::HashMap<(i64, i64), Vec<usize>> =
|
||
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<f64>, Coord<f64>, 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<usize> = 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<f64>, 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<I>(
|
||
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<Item = (Coord<f64>, Coord<f64>, i32)>,
|
||
{
|
||
let pt = Point::new(lng, lat);
|
||
let mut best: Option<(f64, Point<f64>, (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<f64>,
|
||
offset_m: Option<f64>,
|
||
metadata_track: Option<Vec<[f64; 2]>>,
|
||
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<f64>, 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::<Vec<_>>(),
|
||
)
|
||
.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<f64>,
|
||
max_lat: Option<f64>,
|
||
min_lng: Option<f64>,
|
||
max_lng: Option<f64>,
|
||
video_name: Option<String>,
|
||
max_distance_m: Option<f64>,
|
||
offset_m: Option<f64>,
|
||
centerline_names: Option<Vec<String>>,
|
||
metadata_track: Option<Vec<[f64; 2]>>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<BulkSnapResult, String> {
|
||
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<String> =
|
||
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<f64>, 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<Asset> = 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<i64> = 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<String, Vec<[f64; 2]>>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<usize, String> {
|
||
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<std::collections::HashMap<String, Vec<[f64; 2]>>, 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<String, Vec<[f64; 2]>> =
|
||
std::collections::HashMap::with_capacity(rows.len());
|
||
for (name, json) in rows {
|
||
match serde_json::from_str::<Vec<[f64; 2]>>(&json) {
|
||
Ok(track) => {
|
||
out.insert(name, track);
|
||
}
|
||
Err(_) => continue,
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn clear_video_metadata(state: State<'_, AppState>) -> Result<usize, String> {
|
||
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<DataSourceCounts, String> {
|
||
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<usize, String> {
|
||
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<usize, String> {
|
||
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<usize, String> {
|
||
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<Vec<AuditRow>, 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<i64> = 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<i64> = 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<i64> =
|
||
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<String, serde_json::Value> =
|
||
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<i64> = 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<i64> = 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<String, serde_json::Value> =
|
||
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<i64> = 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<i64> =
|
||
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::<serde_json::Value>(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<String, serde_json::Value> =
|
||
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<String, serde_json::Value> =
|
||
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<f64> = [lat, slat, elat].into_iter().flatten().collect();
|
||
let lngs: Vec<f64> = [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<Vec<Asset>, 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<bool>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<Vec<Asset>, 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<f64>,
|
||
pub lng: Option<f64>,
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn assets_coords_for_ids(
|
||
ids: Vec<i64>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<Vec<AssetCoord>, String> {
|
||
let mut out: Vec<AssetCoord> = 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<i64>,
|
||
/// 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<i64>,
|
||
/// Rows with `side IS NULL`.
|
||
pub missing_side: Vec<i64>,
|
||
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<f64>,
|
||
metadata_threshold_m: Option<f64>,
|
||
metadata: Option<std::collections::HashMap<String, Vec<[f64; 2]>>>,
|
||
cap: Option<usize>,
|
||
state: State<'_, AppState>,
|
||
) -> Result<QualityReport, String> {
|
||
// 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<f64>, Option<f64>, Option<f64>, Option<f64>)> =
|
||
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<i64> = 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<i64> = 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<f64>, Option<f64>)> =
|
||
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<String, Vec<(i64, String, f64, f64)>> =
|
||
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<i64> = 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<String>,
|
||
}
|
||
|
||
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<Vec<AssetBbox>, 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<usize, String> {
|
||
// 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<String>)> =
|
||
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<serde_json::Value> = 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)
|
||
}
|