deleted export
This commit is contained in:
@@ -2008,26 +2008,44 @@ 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
|
||||||
.fetch_all(&state.pool)
|
);
|
||||||
.await
|
let assets: Vec<Asset> = sqlx::query_as(&sql)
|
||||||
.map_err(|e| e.to_string())?;
|
.fetch_all(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Resolve link partner ids → row_ids. The raw `link_pair_id` is an
|
// Resolve link partner ids → row_ids. The raw `link_pair_id` is an
|
||||||
// internal autoincrement that changes on every re-import, so a CSV
|
// internal autoincrement that changes on every re-import, so a CSV
|
||||||
// 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,7 +2261,11 @@ pub async fn export_assets(
|
|||||||
inject_bbox(&mut obj, "", bb);
|
inject_bbox(&mut obj, "", bb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fixed_arr.push(obj);
|
if a.deleted != 0 {
|
||||||
|
deleted_fixed.push(obj);
|
||||||
|
} else {
|
||||||
|
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)) =
|
||||||
@@ -2271,15 +2301,25 @@ pub async fn export_assets(
|
|||||||
inject_bbox(&mut obj, "end_", bb);
|
inject_bbox(&mut obj, "end_", bb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
range_arr.push(obj);
|
if a.deleted != 0 {
|
||||||
|
deleted_range.push(obj);
|
||||||
|
} else {
|
||||||
|
range_arr.push(obj);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
serde_json::to_string_pretty(&serde_json::json!({
|
let mut envelope = serde_json::Map::new();
|
||||||
"fixed": fixed_arr,
|
envelope.insert("fixed".into(), serde_json::json!(fixed_arr));
|
||||||
"range": range_arr,
|
envelope.insert("range".into(), serde_json::json!(range_arr));
|
||||||
}))
|
if !deleted_fixed.is_empty() {
|
||||||
.map_err(|e| e.to_string())?
|
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())?
|
||||||
}
|
}
|
||||||
"csv" => {
|
"csv" => {
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
|
|||||||
40
src/App.tsx
40
src/App.tsx
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user