export
This commit is contained in:
@@ -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));
|
||||||
|
|||||||
87
src/App.tsx
87
src/App.tsx
@@ -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">
|
||||||
{(
|
{(
|
||||||
|
|||||||
Reference in New Issue
Block a user