This commit is contained in:
2026-05-26 17:01:23 +05:30
parent f83724951f
commit 4ee278a47e
2 changed files with 116 additions and 46 deletions

View File

@@ -1999,11 +1999,33 @@ pub async fn export_assets(
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Pre-fetch all bboxes once so every format branch that wants to surface
// them can do so without re-querying. Grouped by asset_id → slot.
let bbox_rows_all: Vec<(i64, String, f64, f64, f64, f64)> = sqlx::query_as(
"SELECT b.asset_id, b.slot, b.x1, b.y1, b.x2, b.y2
FROM asset_bboxes b
JOIN assets a ON a.id = b.asset_id
WHERE a.deleted = 0",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let mut bbox_by_asset_all: std::collections::HashMap<
i64,
std::collections::HashMap<String, (f64, f64, f64, f64)>,
> = std::collections::HashMap::new();
for (aid, slot, x1, y1, x2, y2) in bbox_rows_all {
bbox_by_asset_all
.entry(aid)
.or_default()
.insert(slot, (x1, y1, x2, y2));
}
let body = match format.to_lowercase().as_str() { let body = match format.to_lowercase().as_str() {
"geojson" | "json" => { "geojson" | "json" => {
let mut features = vec![]; let mut features = vec![];
for a in &assets { for a in &assets {
let props = serde_json::json!({ let mut props = serde_json::json!({
"row_id": a.row_id, "row_id": a.row_id,
"asset_type": a.asset_type, "asset_type": a.asset_type,
"asset_name": a.asset_name, "asset_name": a.asset_name,
@@ -2018,6 +2040,36 @@ pub async fn export_assets(
"link_pair_id": a.link_pair_id, "link_pair_id": a.link_pair_id,
"link_locked": a.link_locked, "link_locked": a.link_locked,
}); });
// Inline bbox into the GeoJSON feature's properties using the
// same prefixed-key convention the importer parses, so a
// GeoJSON round-trip preserves the user's drawn bboxes too.
if let Some(slots) = bbox_by_asset_all.get(&a.id) {
let inject = |m: &mut serde_json::Map<String, serde_json::Value>,
prefix: &str,
bb: &(f64, f64, f64, f64)| {
m.insert(format!("{prefix}bbox_x1"), serde_json::json!(bb.0));
m.insert(format!("{prefix}bbox_y1"), serde_json::json!(bb.1));
m.insert(format!("{prefix}bbox_x2"), serde_json::json!(bb.2));
m.insert(format!("{prefix}bbox_y2"), serde_json::json!(bb.3));
};
if let Some(map) = props.as_object_mut() {
if a.asset_type == "fixed" {
if let Some(bb) = slots.get("fixed") {
inject(map, "", bb);
}
} else if a.asset_type == "range" {
if let Some(bb) = slots.get("start") {
inject(map, "start_", bb);
}
if let Some(bb) = slots.get("mid") {
inject(map, "mid_", bb);
}
if let Some(bb) = slots.get("end") {
inject(map, "end_", bb);
}
}
}
}
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) {
features.push(serde_json::json!({ features.push(serde_json::json!({
@@ -2114,26 +2166,7 @@ pub async fn export_assets(
// the importer parses (`bbox_*` for fixed, `start_bbox_*` / // the importer parses (`bbox_*` for fixed, `start_bbox_*` /
// `mid_bbox_*` / `end_bbox_*` for range) so a re-import preserves // `mid_bbox_*` / `end_bbox_*` for range) so a re-import preserves
// every bbox the user drew. // every bbox the user drew.
let bbox_rows: Vec<(i64, String, f64, f64, f64, f64)> = let bbox_by_asset = &bbox_by_asset_all;
sqlx::query_as(
"SELECT b.asset_id, b.slot, b.x1, b.y1, b.x2, b.y2
FROM asset_bboxes b
JOIN assets a ON a.id = b.asset_id
WHERE a.deleted = 0",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let mut bbox_by_asset: std::collections::HashMap<
i64,
std::collections::HashMap<String, (f64, f64, f64, f64)>,
> = std::collections::HashMap::new();
for (aid, slot, x1, y1, x2, y2) in bbox_rows {
bbox_by_asset
.entry(aid)
.or_default()
.insert(slot, (x1, y1, x2, y2));
}
let inject_bbox = |obj: &mut serde_json::Value, prefix: &str, bb: &(f64, f64, f64, f64)| { let inject_bbox = |obj: &mut serde_json::Value, prefix: &str, bb: &(f64, f64, f64, f64)| {
if let Some(map) = obj.as_object_mut() { if let Some(map) = obj.as_object_mut() {
map.insert(format!("{prefix}bbox_x1"), serde_json::json!(bb.0)); map.insert(format!("{prefix}bbox_x1"), serde_json::json!(bb.0));

View File

@@ -565,6 +565,13 @@ function AppShell({
const [showInScope, setShowInScope] = useState<boolean>(true); const [showInScope, setShowInScope] = useState<boolean>(true);
const [visibilityOpen, setVisibilityOpen] = useState<boolean>(false); const [visibilityOpen, setVisibilityOpen] = useState<boolean>(false);
const [allVideoNames, setAllVideoNames] = useState<string[]>([]); const [allVideoNames, setAllVideoNames] = useState<string[]>([]);
// Some Save dialogs (notably GTK on Linux) hide the file-filter dropdown,
// so users couldn't tell they were even picking a format. We surface the
// choice directly in the side panel — this is the format the next Export
// click will use.
const [exportFormat, setExportFormat] = useState<
"source" | "geojson" | "kml" | "csv"
>("source");
const DUP_EPS_KEY = "kml_map_tool.dup_eps_m"; const DUP_EPS_KEY = "kml_map_tool.dup_eps_m";
const [dupEpsM, setDupEpsM] = useState<number>(() => { const [dupEpsM, setDupEpsM] = useState<number>(() => {
if (typeof window === "undefined") return 3; if (typeof window === "undefined") return 3;
@@ -3103,31 +3110,36 @@ function AppShell({
async function handleExport() { async function handleExport() {
if (busy) return; if (busy) return;
try { try {
// The dropdown next to the button is the source of truth for format.
// We seed the Save dialog with the matching extension so the user
// doesn't have to hunt for the file-filter menu inside it.
const extByFmt: Record<typeof exportFormat, string> = {
source: "json",
geojson: "geojson",
kml: "kml",
csv: "csv",
};
const wantExt = extByFmt[exportFormat];
const picked = await save({ const picked = await save({
filters: [ defaultPath: `assets.${wantExt}`,
{ name: "Source JSON (round-trip)", extensions: ["json"] }, filters: [{ name: exportFormat.toUpperCase(), extensions: [wantExt] }],
{ name: "GeoJSON", extensions: ["geojson"] },
{ name: "KML", extensions: ["kml"] },
{ name: "CSV", extensions: ["csv"] },
],
}); });
if (typeof picked !== "string") return; if (typeof picked !== "string") return;
const lower = picked.toLowerCase(); // Auto-append the chosen extension if the user typed a bare name.
const fmt = lower.endsWith(".kml") let outPath = picked;
? "kml" if (!outPath.toLowerCase().endsWith(`.${wantExt}`)) {
: lower.endsWith(".csv") outPath = `${outPath}.${wantExt}`;
? "csv" }
: lower.endsWith(".json")
? "source"
: "geojson";
setBusy(true); setBusy(true);
const count = await invoke<number>("export_assets", { const count = await invoke<number>("export_assets", {
path: picked, path: outPath,
format: fmt, format: exportFormat,
}); });
setStatus(`Exported ${count} asset(s) to ${picked}`); setStatus(`Exported ${count} asset(s) to ${outPath}`);
showToast(`Exported ${count} asset(s) (${exportFormat})`);
} catch (e) { } catch (e) {
setStatus(`Export failed: ${e}`); setStatus(`Export failed: ${e}`);
showToast(`Export failed: ${e}`);
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -6165,15 +6177,40 @@ function AppShell({
</button> </button>
</div> </div>
)} )}
<button <div
className="primary-btn" style={{
style={{ marginTop: 6, background: "#2c3e50" }} display: "flex",
onClick={handleExport} gap: 6,
disabled={busy} marginTop: 6,
title="Export all live assets. Pick .json for round-trip (re-importable), .geojson / .kml for GIS, .csv for spreadsheet." alignItems: "stretch",
}}
> >
Export data <select
</button> value={exportFormat}
onChange={(e) =>
setExportFormat(
e.target.value as "source" | "geojson" | "kml" | "csv",
)
}
disabled={busy}
title="File format for the next export"
style={{ fontSize: 12, padding: "2px 4px" }}
>
<option value="source">JSON (round-trip + bbox)</option>
<option value="geojson">GeoJSON</option>
<option value="kml">KML</option>
<option value="csv">CSV</option>
</select>
<button
className="primary-btn"
style={{ background: "#2c3e50", flex: 1 }}
onClick={handleExport}
disabled={busy}
title="Export all live assets in the selected format. JSON is the only re-importable format and includes bbox overlays."
>
Export data
</button>
</div>
<h3 style={{ marginTop: 16 }}>Layers</h3> <h3 style={{ marginTop: 16 }}>Layers</h3>
<div className="layer-toggles"> <div className="layer-toggles">
{( {(