deleted export

This commit is contained in:
2026-07-01 14:29:19 +05:30
parent d83ce69a00
commit 84f1a64fd3
2 changed files with 98 additions and 22 deletions

View File

@@ -2008,15 +2008,33 @@ pub async fn delete_assets_bulk(
pub async fn export_assets( pub async fn export_assets(
path: String, path: String,
format: String, format: String,
// Both default to false → live-only export (existing behavior).
// include_deleted = true → live + deleted rows in one list
// only_deleted = true → deleted rows only (implies include)
// The UI drives these from the Visibility panel's own toggles so the
// export scope always matches what the user is looking at.
#[allow(non_snake_case)] include_deleted: Option<bool>,
#[allow(non_snake_case)] only_deleted: Option<bool>,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<usize, String> { ) -> Result<usize, String> {
let assets: Vec<Asset> = sqlx::query_as( let include_deleted = include_deleted.unwrap_or(false);
let only_deleted = only_deleted.unwrap_or(false);
let where_clause = if only_deleted {
"WHERE deleted = 1"
} else if include_deleted {
"WHERE 1 = 1"
} else {
"WHERE deleted = 0"
};
let sql = format!(
"SELECT id, row_id, asset_type, asset_name, video_name, "SELECT id, row_id, asset_type, asset_name, video_name,
lat, lng, start_lat, start_lng, end_lat, end_lng, lat, lng, start_lat, start_lng, end_lat, end_lng,
image_path, image_path1, image_path2, image_path3, image_path, image_path1, image_path2, image_path3,
deleted, modified, merged_into, in_scope, side, lane_side, link_pair_id, link_locked deleted, modified, merged_into, in_scope, side, lane_side, link_pair_id, link_locked
FROM assets WHERE deleted = 0 ORDER BY asset_type, asset_name, id", FROM assets {} ORDER BY asset_type, asset_name, id",
) where_clause
);
let assets: Vec<Asset> = sqlx::query_as(&sql)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -2026,8 +2044,8 @@ pub async fn export_assets(
// consumer can't tell who linked to whom by looking at the number. // consumer can't tell who linked to whom by looking at the number.
// Look up the partner's row_id once and surface it as a parallel column // Look up the partner's row_id once and surface it as a parallel column
// ("link_pair_row_id") in every format. Falls back to None when the // ("link_pair_row_id") in every format. Falls back to None when the
// partner row is deleted (and thus not in `assets`) — flagged so the // partner row isn't in the exported set (e.g. deleted while we're
// user can see the dangling link. // exporting live-only) — flagged so the user can see the dangling link.
let row_id_by_id: std::collections::HashMap<i64, String> = assets let row_id_by_id: std::collections::HashMap<i64, String> = assets
.iter() .iter()
.map(|a| (a.id, a.row_id.clone())) .map(|a| (a.id, a.row_id.clone()))
@@ -2036,14 +2054,16 @@ pub async fn export_assets(
pid.and_then(|p| row_id_by_id.get(&p).cloned()) pid.and_then(|p| row_id_by_id.get(&p).cloned())
}; };
// Pre-fetch all bboxes once so every format branch that wants to surface // Pre-fetch all bboxes for the rows we're exporting. Match the same
// them can do so without re-querying. Grouped by asset_id → slot. // deleted-scope so a deleted row's bboxes ride along when it does.
let bbox_rows_all: Vec<(i64, String, f64, f64, f64, f64)> = sqlx::query_as( let bbox_sql = format!(
"SELECT b.asset_id, b.slot, b.x1, b.y1, b.x2, b.y2 "SELECT b.asset_id, b.slot, b.x1, b.y1, b.x2, b.y2
FROM asset_bboxes b FROM asset_bboxes b
JOIN assets a ON a.id = b.asset_id JOIN assets a ON a.id = b.asset_id
WHERE a.deleted = 0", {}",
) where_clause.replace("deleted", "a.deleted")
);
let bbox_rows_all: Vec<(i64, String, f64, f64, f64, f64)> = sqlx::query_as(&bbox_sql)
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -2213,8 +2233,14 @@ pub async fn export_assets(
map.insert(format!("{prefix}bbox_y2"), serde_json::json!(bb.3)); map.insert(format!("{prefix}bbox_y2"), serde_json::json!(bb.3));
} }
}; };
// Bucket into four lists so the envelope can split live from
// deleted rows explicitly. Downstream scripts that only care
// about live data ignore the `deleted_*` keys; audit tools read
// them. Empty lists are omitted from the envelope.
let mut fixed_arr: Vec<serde_json::Value> = vec![]; let mut fixed_arr: Vec<serde_json::Value> = vec![];
let mut range_arr: Vec<serde_json::Value> = vec![]; let mut range_arr: Vec<serde_json::Value> = vec![];
let mut deleted_fixed: Vec<serde_json::Value> = vec![];
let mut deleted_range: Vec<serde_json::Value> = vec![];
for a in &assets { for a in &assets {
if a.asset_type == "fixed" { if a.asset_type == "fixed" {
if let (Some(la), Some(ln)) = (a.lat, a.lng) { if let (Some(la), Some(ln)) = (a.lat, a.lng) {
@@ -2235,8 +2261,12 @@ pub async fn export_assets(
inject_bbox(&mut obj, "", bb); inject_bbox(&mut obj, "", bb);
} }
} }
if a.deleted != 0 {
deleted_fixed.push(obj);
} else {
fixed_arr.push(obj); fixed_arr.push(obj);
} }
}
} else if a.asset_type == "range" { } else if a.asset_type == "range" {
if let (Some(la), Some(ln), Some(sla), Some(sln), Some(ela), Some(eln)) = 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) (a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng)
@@ -2271,14 +2301,24 @@ pub async fn export_assets(
inject_bbox(&mut obj, "end_", bb); inject_bbox(&mut obj, "end_", bb);
} }
} }
if a.deleted != 0 {
deleted_range.push(obj);
} else {
range_arr.push(obj); range_arr.push(obj);
} }
} }
} }
serde_json::to_string_pretty(&serde_json::json!({ }
"fixed": fixed_arr, let mut envelope = serde_json::Map::new();
"range": range_arr, envelope.insert("fixed".into(), serde_json::json!(fixed_arr));
})) envelope.insert("range".into(), serde_json::json!(range_arr));
if !deleted_fixed.is_empty() {
envelope.insert("deleted_fixed".into(), serde_json::json!(deleted_fixed));
}
if !deleted_range.is_empty() {
envelope.insert("deleted_range".into(), serde_json::json!(deleted_range));
}
serde_json::to_string_pretty(&serde_json::Value::Object(envelope))
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
} }
"csv" => { "csv" => {

View File

@@ -3423,12 +3423,24 @@ function AppShell({
outPath = `${outPath}.${wantExt}`; outPath = `${outPath}.${wantExt}`;
} }
setBusy(true); setBusy(true);
// Match the Visibility panel's own toggles so what's exported mirrors
// what the user is looking at on the map:
// Only deleted → deleted rows only
// Show deleted → live + deleted in one export
// (neither on) → live rows only (default)
const count = await invoke<number>("export_assets", { const count = await invoke<number>("export_assets", {
path: outPath, path: outPath,
format: exportFormat, format: exportFormat,
includeDeleted: showDeleted,
onlyDeleted: onlyDeleted,
}); });
setStatus(`Exported ${count} asset(s) to ${outPath}`); const scopeLabel = onlyDeleted
showToast(`Exported ${count} asset(s) (${exportFormat})`); ? "deleted-only"
: showDeleted
? "live + deleted"
: "live";
setStatus(`Exported ${count} asset(s) to ${outPath} (${scopeLabel})`);
showToast(`Exported ${count} asset(s) (${exportFormat}, ${scopeLabel})`);
} catch (e) { } catch (e) {
setStatus(`Export failed: ${e}`); setStatus(`Export failed: ${e}`);
showToast(`Export failed: ${e}`); showToast(`Export failed: ${e}`);
@@ -6900,6 +6912,30 @@ function AppShell({
> >
Export data Export data
</button> </button>
<div
style={{
marginTop: 4,
fontSize: 10,
color: onlyDeleted
? "#c0392b"
: showDeleted
? "#d35400"
: "#7f8c8d",
lineHeight: 1.3,
}}
title="Export scope follows the Visibility filters on the left panel"
>
Scope:{" "}
<strong>
{onlyDeleted
? "deleted only"
: showDeleted
? "live + deleted"
: "live only"}
</strong>{" "}
toggle <em>Show deleted</em> / <em>Only deleted</em> in the
Visibility panel to change.
</div>
</div> </div>
<h3 style={{ marginTop: 16 }}>Layers</h3> <h3 style={{ marginTop: 16 }}>Layers</h3>
<div className="layer-toggles"> <div className="layer-toggles">