diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1e72e93..cbad6ae 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1999,11 +1999,33 @@ pub async fn export_assets( .await .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, + > = 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() { "geojson" | "json" => { let mut features = vec![]; for a in &assets { - let props = serde_json::json!({ + let mut props = serde_json::json!({ "row_id": a.row_id, "asset_type": a.asset_type, "asset_name": a.asset_name, @@ -2018,6 +2040,36 @@ pub async fn export_assets( "link_pair_id": a.link_pair_id, "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, + 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 let (Some(la), Some(ln)) = (a.lat, a.lng) { features.push(serde_json::json!({ @@ -2114,26 +2166,7 @@ pub async fn export_assets( // the importer parses (`bbox_*` for fixed, `start_bbox_*` / // `mid_bbox_*` / `end_bbox_*` for range) so a re-import preserves // every bbox the user drew. - let bbox_rows: 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: std::collections::HashMap< - i64, - std::collections::HashMap, - > = 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 bbox_by_asset = &bbox_by_asset_all; let inject_bbox = |obj: &mut serde_json::Value, prefix: &str, bb: &(f64, f64, f64, f64)| { if let Some(map) = obj.as_object_mut() { map.insert(format!("{prefix}bbox_x1"), serde_json::json!(bb.0)); diff --git a/src/App.tsx b/src/App.tsx index 06dcbc0..00ac8e1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -565,6 +565,13 @@ function AppShell({ const [showInScope, setShowInScope] = useState(true); const [visibilityOpen, setVisibilityOpen] = useState(false); const [allVideoNames, setAllVideoNames] = useState([]); + // 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 [dupEpsM, setDupEpsM] = useState(() => { if (typeof window === "undefined") return 3; @@ -3103,31 +3110,36 @@ function AppShell({ async function handleExport() { if (busy) return; 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 = { + source: "json", + geojson: "geojson", + kml: "kml", + csv: "csv", + }; + const wantExt = extByFmt[exportFormat]; const picked = await save({ - filters: [ - { name: "Source JSON (round-trip)", extensions: ["json"] }, - { name: "GeoJSON", extensions: ["geojson"] }, - { name: "KML", extensions: ["kml"] }, - { name: "CSV", extensions: ["csv"] }, - ], + defaultPath: `assets.${wantExt}`, + filters: [{ name: exportFormat.toUpperCase(), extensions: [wantExt] }], }); if (typeof picked !== "string") return; - const lower = picked.toLowerCase(); - const fmt = lower.endsWith(".kml") - ? "kml" - : lower.endsWith(".csv") - ? "csv" - : lower.endsWith(".json") - ? "source" - : "geojson"; + // Auto-append the chosen extension if the user typed a bare name. + let outPath = picked; + if (!outPath.toLowerCase().endsWith(`.${wantExt}`)) { + outPath = `${outPath}.${wantExt}`; + } setBusy(true); const count = await invoke("export_assets", { - path: picked, - format: fmt, + path: outPath, + format: exportFormat, }); - setStatus(`Exported ${count} asset(s) to ${picked}`); + setStatus(`Exported ${count} asset(s) to ${outPath}`); + showToast(`Exported ${count} asset(s) (${exportFormat})`); } catch (e) { setStatus(`Export failed: ${e}`); + showToast(`Export failed: ${e}`); } finally { setBusy(false); } @@ -6165,15 +6177,40 @@ function AppShell({ )} - + + +

Layers

{(