use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::io::Cursor; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use acadrust::entities::{table::CellRange, TableBuilder}; use acadrust::objects::{ClassObject, ClassObjectData, DataLink, DataLinkCustomData, ObjectType}; use acadrust::types::{Handle, Vector3}; use acadrust::{CadDocument, EntityType}; use iced::Task; use super::{Message, ModalKind, OpenCADStudio}; use crate::command::CadCommand; use crate::ui::window::annotation_data::{ DataExtractionField, DataExtractionState, DataLinkChoice, DataLinkField, ExtractionBegin, ExtractionPage, ExtractionSource, LinkPathType, LinkRange, TableInsertField, TableInsertion, TableSource, }; #[derive(Clone)] struct ExtractionRecord { object_type: String, handle: String, layer: String, color: String, linetype: String, details: String, } // ── In-session picked-file registries (web) ─────────────────────────────── // // The browser exposes no reusable filesystem path: a picked file's bytes exist // only while the app keeps them. Both registries below hold those bytes (and // what was parsed from them) keyed by the reference the drawing or wizard // carries, following the image-model / PDF-raster pattern. Native builds never // fill them — the real path stays readable — but the lookup and matching // logic compiles everywhere so it stays unit-testable. fn picked_sources() -> &'static Mutex>>> { static SOURCES: OnceLock>>>> = OnceLock::new(); SOURCES.get_or_init(|| Mutex::new(HashMap::new())) } #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] fn extraction_documents() -> &'static Mutex>> { static DOCUMENTS: OnceLock>>> = OnceLock::new(); DOCUMENTS.get_or_init(|| Mutex::new(HashMap::new())) } /// Lower-cased trailing component of a path-style reference. Links and wizard /// sources carry whatever the author stored, so the file name — not the full /// path — is the only component a re-picked file can be expected to match. fn source_name_key(path: &str) -> String { path.rsplit(['/', '\\']) .next() .unwrap_or_default() .to_ascii_lowercase() } /// Do two source references name the same file? Compares trailing components /// case-insensitively (Windows paths are case-insensitive where the link was /// authored, and the web has no path beyond the name at all). fn same_source_name(a: &str, b: &str) -> bool { let (a, b) = (source_name_key(a), source_name_key(b)); !a.is_empty() && a == b } /// Keep the bytes of a spreadsheet or settings file chosen through a picker. /// Re-opening the link in-session re-reads these instead of demanding a new /// pick; re-picking the same file overwrites the entry. // Only the web registers sources (native re-reads the real file, so a file // edited after picking is picked up); tests exercise the registry directly. #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] /// Returns false when the registration REPLACED different bytes under the /// same key — the browser hands back bare file names, so two distinct files /// named alike collide and every reference to that name would silently read /// the newer pick. Callers surface that to the user. pub(crate) fn register_picked_source(path: &str, bytes: Arc>) -> bool { let mut cache = picked_sources() .lock() .unwrap_or_else(|error| error.into_inner()); let replaced = cache .get(path) .is_some_and(|old| old.as_slice() != bytes.as_slice()); if replaced { take_last_collision().replace(file_name_of(path)); } cache.insert(path.to_string(), bytes); !replaced } /// The name whose re-pick was refused because different bytes are already /// registered under it — the browse handler reports and clears it. fn last_collision() -> &'static Mutex> { static LAST: OnceLock>> = OnceLock::new(); LAST.get_or_init(|| Mutex::new(None)) } fn take_last_collision() -> std::sync::MutexGuard<'static, Option> { last_collision() .lock() .unwrap_or_else(|error| error.into_inner()) } fn file_name_of(path: &str) -> String { std::path::Path::new(path) .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string()) } /// Bytes registered for this reference — exact key first, then any entry that /// names the same file (the web fallback for links whose stored path is the /// original author's, while the user picked only the bare file). pub(crate) fn picked_source_bytes(path: &str) -> Option>> { let cache = picked_sources() .lock() .unwrap_or_else(|error| error.into_inner()); if let Some(bytes) = cache.get(path) { return Some(Arc::clone(bytes)); } cache .iter() .find(|(key, _)| same_source_name(key, path)) .map(|(_, bytes)| Arc::clone(bytes)) } /// Keep a source drawing parsed at pick time, keyed by the name the wizard /// lists. The extraction pipeline reads these on the web instead of calling /// the path-based `load_file`. #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) fn register_extraction_document(name: &str, document: CadDocument) { extraction_documents() .lock() .unwrap_or_else(|error| error.into_inner()) .insert(name.to_string(), Arc::new(document)); } /// The parsed drawing registered under this source reference, matched by exact /// key or by file name. #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) fn extraction_document(path: &str) -> Option> { let cache = extraction_documents() .lock() .unwrap_or_else(|error| error.into_inner()); if let Some(document) = cache.get(path) { return Some(Arc::clone(document)); } cache .iter() .find(|(key, _)| same_source_name(key, path)) .map(|(_, document)| Arc::clone(document)) } impl ExtractionRecord { fn value(&self, key: &str) -> String { match key { "type" => self.object_type.clone(), "handle" => self.handle.clone(), "layer" => self.layer.clone(), "color" => self.color.clone(), "linetype" => self.linetype.clone(), "details" => self.details.clone(), _ => String::new(), } } } fn table_styles(doc: &CadDocument) -> Vec { let mut styles: Vec = doc .objects .iter() .filter_map(|(_, object)| match object { ObjectType::TableStyle(style) => Some(style.name.clone()), _ => None, }) .collect(); styles.sort_by_key(|name| name.to_ascii_lowercase()); styles.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); if styles.is_empty() { styles.push("Standard".into()); } styles } fn link_display_name(link: &DataLink) -> String { if !link.description.trim().is_empty() && link.description != link.connection_string { return link.description.clone(); } Path::new(&link.connection_string) .file_stem() .map(|value| value.to_string_lossy().into_owned()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "Data Link".into()) } fn resolve_link_path(doc: &CadDocument, link: &DataLink) -> PathBuf { let stored = PathBuf::from(&link.connection_string); if stored.is_absolute() || link.path_option == 1 { return stored; } doc.source_path .as_deref() .map(Path::new) .and_then(Path::parent) .map(|parent| parent.join(&stored)) .unwrap_or(stored) } fn data_link_choices(doc: &CadDocument) -> Vec { let mut links = doc .objects .iter() .filter_map(|(handle, object)| match object { ObjectType::ClassObject(object) => match &object.data { ClassObjectData::DataLink(link) => { let path = resolve_link_path(doc, link); let path_text = path.to_string_lossy().into_owned(); // The web has no filesystem: a link is valid when its // picked bytes are still registered in-session. let valid = path.is_file() || picked_source_bytes(&path_text).is_some(); Some(DataLinkChoice { handle: *handle, name: link_display_name(link), path: path_text, valid, }) } _ => None, }, _ => None, }) .collect::>(); links.sort_by_key(|link| link.name.to_ascii_lowercase()); links } fn data_link<'a>(doc: &'a CadDocument, handle: Handle) -> Option<&'a DataLink> { match doc.objects.get(&handle) { Some(ObjectType::ClassObject(object)) => match &object.data { ClassObjectData::DataLink(link) => Some(link), _ => None, }, _ => None, } } fn data_link_mut<'a>(doc: &'a mut CadDocument, handle: Handle) -> Option<&'a mut DataLink> { match doc.objects.get_mut(&handle) { Some(ObjectType::ClassObject(object)) => match &mut object.data { ClassObjectData::DataLink(link) => Some(link), _ => None, }, _ => None, } } fn column_index(text: &str) -> Option { let mut value = 0usize; let mut any = false; for byte in text.trim().bytes() { if !byte.is_ascii_alphabetic() { break; } any = true; value = value.checked_mul(26)?.checked_add((byte.to_ascii_uppercase() - b'A' + 1) as usize)?; } any.then_some(value.saturating_sub(1)) } fn cell_ref(text: &str) -> Option<(usize, usize)> { let split = text.find(|c: char| c.is_ascii_digit())?; let column = column_index(&text[..split])?; let row = text[split..].trim().parse::().ok()?.checked_sub(1)?; Some((row, column)) } fn crop_range(rows: Vec>, range: &str) -> Result>, String> { let (start, end) = range.split_once(':').unwrap_or((range, range)); let (Some((r0, c0)), Some((r1, c1))) = (cell_ref(start), cell_ref(end)) else { return Err(crate::t!("Enter a valid cell range such as A1:D10.").into_owned()); }; let (r0, r1) = (r0.min(r1), r0.max(r1)); let (c0, c1) = (c0.min(c1), c0.max(c1)); Ok(rows.into_iter() .skip(r0) .take(r1 - r0 + 1) .map(|row| { (c0..=c1) .map(|column| row.get(column).cloned().unwrap_or_default()) .collect() }) .collect()) } fn workbook_metadata(path: &Path) -> Result<(Vec, Vec), String> { let ext = path .extension() .and_then(|value| value.to_str()) .unwrap_or("") .to_ascii_lowercase(); if ext == "csv" || ext == "txt" { return Ok((Vec::new(), Vec::new())); } if let Some(bytes) = picked_source_bytes(&path.to_string_lossy()) { return workbook_metadata_bytes(&bytes); } if cfg!(target_arch = "wasm32") { return Err(crate::t!("Pick the file again to load its contents in the browser.").into_owned()); } use calamine::{open_workbook_auto, Reader}; let workbook = open_workbook_auto(path).map_err(|error| error.to_string())?; let sheets = workbook.sheet_names(); let names = workbook .defined_names() .iter() .map(|(name, _)| name.clone()) .collect(); Ok((sheets, names)) } /// Sheet and defined-name catalog read straight from bytes (a picked file on /// the web, where no path exists to reopen). fn workbook_metadata_bytes(bytes: &[u8]) -> Result<(Vec, Vec), String> { use calamine::{open_workbook_auto_from_rs, Reader}; let workbook = open_workbook_auto_from_rs(Cursor::new(bytes)).map_err(|error| error.to_string())?; let sheets = workbook.sheet_names(); let names = workbook .defined_names() .iter() .map(|(name, _)| name.clone()) .collect(); Ok((sheets, names)) } fn named_range_target(formula: &str) -> Option<(String, String)> { let formula = formula.trim().trim_start_matches('='); let (sheet, range) = formula.rsplit_once('!')?; let sheet = sheet .trim() .trim_matches('\'') .replace("''", "'"); let range = range.replace('$', ""); (!sheet.is_empty() && !range.is_empty()).then_some((sheet, range)) } /// Read the text of a CSV/TXT (or settings) source: registered picked bytes /// first, then the native filesystem. On the web an unregistered path has /// nothing behind it, so it asks for the pick instead of failing on I/O. fn read_text_source(path: &Path) -> Result { if let Some(bytes) = picked_source_bytes(&path.to_string_lossy()) { return Ok(String::from_utf8_lossy(&bytes).into_owned()); } if cfg!(target_arch = "wasm32") { return Err(crate::t!("Pick the file again to load its contents in the browser.").into_owned()); } std::fs::read_to_string(path).map_err(|error| error.to_string()) } /// Sheet selection + range resolution shared by the path- and bytes-based /// workbook readers. Returns the raw rows and the cell range to crop to, if /// any. fn workbook_rows( workbook: &mut calamine::Sheets, sheet: Option<&str>, range_kind: LinkRange, range_value: Option<&str>, ) -> Result<(Vec>, Option), String> { use calamine::Reader; let (selected_sheet, cell_range) = if range_kind == LinkRange::NamedRange { let name = range_value .filter(|value| !value.trim().is_empty()) .ok_or_else(|| crate::t!("Select a named range.").into_owned())?; let formula = workbook .defined_names() .iter() .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) .map(|(_, formula)| formula.clone()) .ok_or_else(|| crate::t!("The named range was not found.").into_owned())?; named_range_target(&formula) .ok_or_else(|| crate::t!("The named range does not refer to a worksheet cell range.").into_owned())? } else { let selected_sheet = sheet .filter(|value| !value.trim().is_empty()) .map(str::to_string) .or_else(|| workbook.sheet_names().first().cloned()) .ok_or_else(|| crate::t!("The spreadsheet contains no worksheets.").into_owned())?; let range = (range_kind == LinkRange::CellRange) .then_some(range_value) .flatten() .filter(|value| !value.trim().is_empty()) .map(str::to_string) .unwrap_or_default(); (selected_sheet, range) }; let data = workbook .worksheet_range(&selected_sheet) .map_err(|error| error.to_string())?; Ok(( data.rows() .map(|row| row.iter().map(ToString::to_string).collect()) .collect(), (!cell_range.is_empty()).then_some(cell_range), )) } pub(crate) fn read_tabular_file( path: &Path, sheet: Option<&str>, range_kind: LinkRange, range_value: Option<&str>, ) -> Result>, String> { let ext = path.extension().and_then(|value| value.to_str()).unwrap_or("").to_ascii_lowercase(); let (rows, cell_range) = if ext == "csv" || ext == "txt" { if range_kind == LinkRange::NamedRange { return Err(crate::t!("Named ranges require a spreadsheet file.").into_owned()); } ( crate::app::commands::display::parse_csv_table(&read_text_source(path)?), (range_kind == LinkRange::CellRange) .then_some(range_value) .flatten() .map(str::to_string), ) } else if let Some(bytes) = picked_source_bytes(&path.to_string_lossy()) { use calamine::open_workbook_auto_from_rs; let mut workbook = open_workbook_auto_from_rs(Cursor::new(bytes.as_slice())) .map_err(|error| error.to_string())?; workbook_rows(&mut workbook, sheet, range_kind, range_value)? } else if cfg!(target_arch = "wasm32") { return Err(crate::t!("Pick the file again to load its contents in the browser.").into_owned()); } else { use calamine::open_workbook_auto; let mut workbook = open_workbook_auto(path).map_err(|error| error.to_string())?; workbook_rows(&mut workbook, sheet, range_kind, range_value)? }; let rows = match cell_range.as_deref().filter(|value| !value.trim().is_empty()) { Some(value) => crop_range(rows, value)?, None => rows, }; if rows.is_empty() || rows.iter().all(Vec::is_empty) { Err(crate::t!("The selected data source is empty.").into_owned()) } else { Ok(rows) } } fn table_style_handle(doc: &CadDocument, name: &str) -> Option { doc.objects.iter().find_map(|(handle, object)| match object { ObjectType::TableStyle(style) if style.name.eq_ignore_ascii_case(name) => Some(*handle), _ => None, }) } fn build_table(rows: &[Vec], style: Option, title: Option<&str>) -> acadrust::entities::Table { let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); let title_rows = usize::from(title.is_some_and(|value| !value.trim().is_empty())); let mut table = TableBuilder::new(rows.len().max(1) + title_rows, columns) .at(Vector3::new(0.0, 0.0, 0.0)) .row_height(0.5) .column_width(2.0) .build(); table.table_style_handle = style; if let Some(title) = title.filter(|value| !value.trim().is_empty()) { table.set_cell_text(0, 0, title); if columns > 1 { table.merge_cells(CellRange::new(0, 0, 0, columns - 1)); } } for (row_index, row) in rows.iter().enumerate() { for (column_index, value) in row.iter().enumerate() { table.set_cell_text(row_index + title_rows, column_index, value); } } table } fn link_selection(link: &DataLink) -> (Option, LinkRange, Option) { let mut sheet = None; let mut kind = LinkRange::EntireSheet; let mut range = None; for item in &link.custom_data { if let Some(value) = item.value.strip_prefix("sheet:") { sheet = (!value.is_empty()).then(|| value.to_string()); } else if let Some(value) = item.value.strip_prefix("range-kind:") { kind = if value.eq_ignore_ascii_case("named") { LinkRange::NamedRange } else if value.eq_ignore_ascii_case("cell") { LinkRange::CellRange } else { LinkRange::EntireSheet }; } else if let Some(value) = item.value.strip_prefix("range:") { range = (!value.is_empty()).then(|| value.to_string()); if kind == LinkRange::EntireSheet { kind = LinkRange::CellRange; } } } (sheet, kind, range) } fn link_custom_data(sheet: &str, kind: LinkRange, range: Option<&str>) -> Vec { let mut values = Vec::new(); if !sheet.trim().is_empty() { values.push(DataLinkCustomData { target: Handle::NULL, value: format!("sheet:{}", sheet.trim()), }); } if kind != LinkRange::EntireSheet { values.push(DataLinkCustomData { target: Handle::NULL, value: format!( "range-kind:{}", if kind == LinkRange::NamedRange { "named" } else { "cell" } ), }); if let Some(range) = range.filter(|value| !value.trim().is_empty()) { values.push(DataLinkCustomData { target: Handle::NULL, value: format!("range:{}", range.trim()), }); } } values } fn read_link_rows(doc: &CadDocument, link: &DataLink) -> Result>, String> { let (sheet, kind, range) = link_selection(link); read_tabular_file( &resolve_link_path(doc, link), sheet.as_deref(), kind, range.as_deref(), ) } pub(crate) fn read_data_link( doc: &CadDocument, handle: Handle, ) -> Result>, String> { let link = data_link(doc, handle) .ok_or_else(|| crate::t!("The data link no longer exists.").into_owned())?; read_link_rows(doc, link) } pub(crate) fn data_link_write_path( doc: &CadDocument, handle: Handle, ) -> Result { let link = data_link(doc, handle) .ok_or_else(|| crate::t!("The data link no longer exists.").into_owned())?; if link.option & 1 == 0 { return Err(crate::t!("Writing to this data link is disabled.").into_owned()); } let path = resolve_link_path(doc, link); let writable = path .extension() .and_then(|value| value.to_str()) .is_some_and(|value| value.eq_ignore_ascii_case("csv") || value.eq_ignore_ascii_case("txt")); if !writable { return Err(crate::t!("Writing linked data is supported for CSV and text sources.").into_owned()); } Ok(path) } fn entity_details(entity: &EntityType) -> String { match entity { EntityType::Line(value) => format!("({:.3},{:.3},{:.3})-({:.3},{:.3},{:.3})", value.start.x, value.start.y, value.start.z, value.end.x, value.end.y, value.end.z), EntityType::Circle(value) => format!("C({:.3},{:.3},{:.3}) R={:.3}", value.center.x, value.center.y, value.center.z, value.radius), EntityType::Arc(value) => format!("C({:.3},{:.3},{:.3}) R={:.3} {:.1}°-{:.1}°", value.center.x, value.center.y, value.center.z, value.radius, value.start_angle.to_degrees(), value.end_angle.to_degrees()), EntityType::Text(value) => value.value.clone(), EntityType::MText(value) => value.value.clone(), EntityType::Insert(value) => format!("{} @({:.3},{:.3},{:.3})", value.block_name, value.insert_point.x, value.insert_point.y, value.insert_point.z), EntityType::LwPolyline(value) => format!("{} vertices", value.vertices.len()), EntityType::Polyline(value) => format!("{} vertices", value.vertices.len()), EntityType::Polyline2D(value) => format!("{} vertices", value.vertices.len()), EntityType::Polyline3D(value) => format!("{} vertices", value.vertices.len()), EntityType::Hatch(value) => value.pattern.name.clone(), EntityType::Dimension(value) => format!("{:.3}", value.base().actual_measurement), EntityType::Spline(value) => format!("{} control points", value.control_points.len()), _ => String::new(), } } fn collect_records( doc: &CadDocument, allowed_handles: Option<&BTreeSet>, allowed_types: &BTreeSet, out: &mut Vec, ) { for entity in doc.entities() { if matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_)) { continue; } if allowed_handles.is_some_and(|handles| !handles.contains(&entity.common().handle)) { continue; } let object_type = crate::entities::names::dxf_name(entity).to_string(); if !allowed_types.is_empty() && !allowed_types.contains(&object_type) { continue; } out.push(ExtractionRecord { object_type, handle: format!("{:X}", entity.common().handle.value()), layer: entity.common().layer.clone(), color: entity.common().color.to_string(), linetype: entity.common().linetype.clone(), details: entity_details(entity), }); } } #[cfg(not(target_arch = "wasm32"))] fn is_drawing_file(path: &Path) -> bool { path.extension() .and_then(|value| value.to_str()) .is_some_and(|value| value.eq_ignore_ascii_case("dwg") || value.eq_ignore_ascii_case("dxf")) } #[cfg(not(target_arch = "wasm32"))] fn collect_drawing_paths( path: &Path, recursive: bool, out: &mut Vec, ) -> Result<(), String> { if path.is_file() { if is_drawing_file(path) { out.push(path.to_path_buf()); } return Ok(()); } if !path.is_dir() { return Err(crate::tf!("Data source not found: %{path}", path = path.display()).into_owned()); } for entry in std::fs::read_dir(path).map_err(|error| error.to_string())? { let entry = entry.map_err(|error| error.to_string())?; let child = entry.path(); if child.is_file() && is_drawing_file(&child) { out.push(child); } else if recursive && child.is_dir() { collect_drawing_paths(&child, true, out)?; } } Ok(()) } #[cfg(not(target_arch = "wasm32"))] fn extraction_drawing_paths(state: &DataExtractionState) -> Result, String> { let mut paths = Vec::new(); for source in &state.source_files { collect_drawing_paths(Path::new(source), state.include_subfolders, &mut paths)?; } paths.sort_by_key(|path| path.to_string_lossy().to_ascii_lowercase()); paths.dedup_by(|left, right| left == right); Ok(paths) } /// Source drawings the extraction pipeline should read beyond the current /// one. Native loads them from disk (folders walked recursively); the web /// reads the documents parsed at pick time from the in-session registry — /// every listed source must have been picked, or it asks for the pick again. fn extraction_documents_for( state: &DataExtractionState, ) -> Result)>, String> { #[cfg(target_arch = "wasm32")] { let mut documents = Vec::new(); for source in &state.source_files { let document = extraction_document(source).ok_or_else(|| { crate::tf!("Drawing not loaded in the browser: {name}.", name = source).into_owned() })?; documents.push((source.clone(), document)); } Ok(documents) } #[cfg(not(target_arch = "wasm32"))] { let mut documents = Vec::new(); for source in extraction_drawing_paths(state)? { let document = crate::io::load_file(&source) .map_err(|error| format!("{}: {error}", source.display()))?; documents.push((source.to_string_lossy().into_owned(), Arc::new(document))); } Ok(documents) } } fn collect_type_counts( doc: &CadDocument, allowed_handles: Option<&BTreeSet>, counts: &mut BTreeMap, ) { for entity in doc.entities() { if matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_)) { continue; } if allowed_handles.is_some_and(|handles| !handles.contains(&entity.common().handle)) { continue; } *counts .entry(crate::entities::names::dxf_name(entity).to_string()) .or_default() += 1; } } fn extraction_preview(state: &DataExtractionState, current: &CadDocument) -> Result>, String> { let allowed_types = state.objects.iter().filter(|value| value.checked).map(|value| value.name.clone()).collect::>(); let selected = state.selection_handles.iter().copied().collect::>(); let mut records = Vec::new(); match state.source { ExtractionSource::CurrentDrawing => collect_records(current, None, &allowed_types, &mut records), ExtractionSource::CurrentSelection => collect_records(current, Some(&selected), &allowed_types, &mut records), ExtractionSource::DrawingsAndFolders => { if state.include_current { collect_records(current, None, &allowed_types, &mut records); } for (_, document) in extraction_documents_for(state)? { collect_records(&document, None, &allowed_types, &mut records); } } } let props = state.properties.iter().filter(|value| value.checked).collect::>(); if props.is_empty() { return Err(crate::t!("Select at least one property.").into_owned()); } let mut header = Vec::new(); if state.show_name { header.push("Name".to_string()); } header.extend(props.iter().filter(|value| !(state.show_name && value.key == "type")).map(|value| value.name.clone())); if state.show_count { header.push("Count".to_string()); } let mut rows = Vec::new(); if state.combine_identical { let mut grouped: BTreeMap, usize> = BTreeMap::new(); for record in records { let mut values = Vec::new(); if state.show_name { values.push(record.object_type.clone()); } values.extend(props.iter().filter(|value| !(state.show_name && value.key == "type")).map(|value| record.value(&value.key))); *grouped.entry(values).or_default() += 1; } for (mut values, count) in grouped { if state.show_count { values.push(count.to_string()); } rows.push(values); } } else { for record in records { let mut values = Vec::new(); if state.show_name { values.push(record.object_type.clone()); } values.extend(props.iter().filter(|value| !(state.show_name && value.key == "type")).map(|value| record.value(&value.key))); if state.show_count { values.push("1".into()); } rows.push(values); } } let mut output = vec![header]; output.extend(rows); Ok(output) } fn csv_escape(value: &str) -> String { if value.contains([',', '"', '\r', '\n']) { format!("\"{}\"", value.replace('"', "\"\"")) } else { value.to_string() } } fn rows_to_csv(rows: &[Vec]) -> String { let mut csv = String::new(); for row in rows { csv.push_str(&row.iter().map(|value| csv_escape(value)).collect::>().join(",")); csv.push('\n'); } csv } fn save_extraction_settings(state: &DataExtractionState) -> Result<(), String> { if state.settings_path.trim().is_empty() { return Ok(()); } let object_types = state.objects.iter().filter(|value| value.checked).map(|value| value.name.clone()).collect::>(); let properties = state.properties.iter().filter(|value| value.checked).map(|value| value.key.clone()).collect::>(); let value = serde_json::json!({ "version": 1, "source": match state.source { ExtractionSource::CurrentDrawing => "currentDrawing", ExtractionSource::CurrentSelection => "currentSelection", ExtractionSource::DrawingsAndFolders => "drawingsAndFolders", }, "includeCurrent": state.include_current, "includeSubfolders": state.include_subfolders, "sourceFiles": state.source_files, "objectTypes": object_types, "properties": properties, "combineIdentical": state.combine_identical, "showCount": state.show_count, "showName": state.show_name, "outputTable": state.output_table, "outputFile": state.output_file, "outputPath": state.output_path, "tableStyle": state.table_style, "tableTitle": state.table_title, }); let text = serde_json::to_string_pretty(&value).map_err(|error| error.to_string())?; #[cfg(target_arch = "wasm32")] { // No filesystem to write to: hand the settings to the browser as a // download, mirroring the native write of the same JSON. let name = Path::new(state.settings_path.trim()) .file_name() .map(|value| value.to_string_lossy().into_owned()) .unwrap_or_else(|| "extraction.dxex".into()); crate::sys::download_bytes(&name, text.as_bytes()); Ok(()) } #[cfg(not(target_arch = "wasm32"))] { std::fs::write(&state.settings_path, text).map_err(|error| error.to_string()) } } fn load_extraction_settings(state: &mut DataExtractionState) -> Result<(), String> { let text = read_text_source(Path::new(state.settings_path.trim()))?; let value: serde_json::Value = serde_json::from_str(&text).map_err(|error| error.to_string())?; state.source = match value["source"].as_str() { Some("currentSelection") => ExtractionSource::CurrentSelection, Some("drawingsAndFolders") => ExtractionSource::DrawingsAndFolders, _ => ExtractionSource::CurrentDrawing, }; state.include_current = value["includeCurrent"].as_bool().unwrap_or(state.include_current); state.include_subfolders = value["includeSubfolders"].as_bool().unwrap_or(state.include_subfolders); if let Some(files) = value["sourceFiles"].as_array() { state.source_files = files .iter() .filter_map(|value| value.as_str().map(str::to_string)) .collect(); } let object_types = value["objectTypes"].as_array().map(|values| values.iter().filter_map(|value| value.as_str()).collect::>()).unwrap_or_default(); for item in &mut state.objects { item.checked = object_types.is_empty() || object_types.contains(item.name.as_str()); } let properties = value["properties"].as_array().map(|values| values.iter().filter_map(|value| value.as_str()).collect::>()).unwrap_or_default(); for item in &mut state.properties { item.checked = properties.is_empty() || properties.contains(item.key.as_str()); } state.combine_identical = value["combineIdentical"].as_bool().unwrap_or(state.combine_identical); state.show_count = value["showCount"].as_bool().unwrap_or(state.show_count); state.show_name = value["showName"].as_bool().unwrap_or(state.show_name); state.output_table = value["outputTable"].as_bool().unwrap_or(state.output_table); state.output_file = value["outputFile"].as_bool().unwrap_or(state.output_file); state.output_path = value["outputPath"].as_str().unwrap_or(&state.output_path).to_string(); state.table_style = value["tableStyle"].as_str().unwrap_or(&state.table_style).to_string(); state.table_title = value["tableTitle"].as_str().unwrap_or(&state.table_title).to_string(); Ok(()) } impl OpenCADStudio { fn reset_annotation_modal(&mut self, modal: ModalKind) { self.active_modal = Some(modal); self.reset_modal_geometry(); } pub(super) fn open_table_insert(&mut self) { let i = self.active_tab; let doc = &self.tabs[i].scene.document; let styles = table_styles(doc); let current = doc.header.current_table_style_name.clone(); let links = data_link_choices(doc); self.table_insert.styles = styles; self.table_insert.style = current; self.table_insert.links = links; self.table_insert.link = self.table_insert.links.first().cloned(); self.table_insert.error.clear(); self.reset_annotation_modal(ModalKind::InsertTable); } pub(super) fn open_data_link_manager(&mut self, from_table: bool) { self.data_link_parent_table = from_table; self.refresh_data_link_manager(None); self.data_link_manager.editing = false; self.data_link_manager.status.clear(); self.reset_annotation_modal(ModalKind::DataLinkManager); } fn refresh_data_link_manager(&mut self, selected: Option) { let doc = &self.tabs[self.active_tab].scene.document; let links = data_link_choices(doc); let selected = selected .and_then(|handle| links.iter().find(|value| value.handle == handle).cloned()) .or_else(|| self.data_link_manager.selected.as_ref().and_then(|old| links.iter().find(|value| value.handle == old.handle).cloned())) .or_else(|| links.first().cloned()); let preview = selected .as_ref() .and_then(|choice| data_link(doc, choice.handle)) .and_then(|link| read_link_rows(doc, link).ok()) .unwrap_or_default(); self.data_link_manager.links = links; self.data_link_manager.selected = selected; self.data_link_manager.preview = preview; } pub(super) fn open_data_extraction(&mut self) { let i = self.active_tab; let doc = &self.tabs[i].scene.document; let selection_handles = self.tabs[i].scene.selected_entities().into_iter().map(|(handle, _)| handle).collect::>(); let mut counts = BTreeMap::::new(); for entity in doc.entities() { if !matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_)) { *counts.entry(crate::entities::names::dxf_name(entity).to_string()).or_default() += 1; } } let mut state = DataExtractionState::default(); state.selection_handles = selection_handles; state.objects = counts.into_iter().map(|(name, count)| crate::ui::window::annotation_data::ExtractionObject { name, checked: true, count }).collect(); state.table_styles = table_styles(doc); state.table_style = doc.header.current_table_style_name.clone(); self.data_extraction = state; self.reset_annotation_modal(ModalKind::DataExtraction); } pub(super) fn on_table_insert_style(&mut self, style: String) -> Task { self.table_insert.style = style; Task::none() } pub(super) fn on_table_insert_field(&mut self, field: TableInsertField) -> Task { match field { TableInsertField::Style(value) => self.table_insert.style = value, TableInsertField::Source(value) => self.table_insert.source = value, TableInsertField::Link(value) => self.table_insert.link = Some(value), TableInsertField::Insertion(value) => self.table_insert.insertion = value, TableInsertField::Columns(value) => self.table_insert.columns = value, TableInsertField::DataRows(value) => self.table_insert.data_rows = value, TableInsertField::ColumnWidth(value) => self.table_insert.column_width = value, TableInsertField::RowHeight(value) => self.table_insert.row_height = value, TableInsertField::Preview(value) => self.table_insert.preview = value, TableInsertField::FirstRowStyle(value) => self.table_insert.first_row_style = value, TableInsertField::SecondRowStyle(value) => self.table_insert.second_row_style = value, TableInsertField::OtherRowStyle(value) => self.table_insert.other_row_style = value, } self.table_insert.error.clear(); Task::none() } pub(super) fn on_table_insert_apply(&mut self) -> Task { let i = self.active_tab; if self.table_insert.source == TableSource::DataExtraction { self.open_data_extraction(); return Task::none(); } if self.table_insert.source == TableSource::DataLink { let Some(choice) = self.table_insert.link.clone() else { self.table_insert.error = crate::t!("Select a data link.").into_owned(); return Task::none(); }; let doc = &self.tabs[i].scene.document; let Some(link) = data_link(doc, choice.handle) else { self.table_insert.error = crate::t!("The selected data link no longer exists.").into_owned(); return Task::none(); }; let rows = match read_link_rows(doc, link) { Ok(rows) => rows, Err(error) => { self.table_insert.error = error; return Task::none(); } }; let table = build_table(&rows, table_style_handle(doc, &self.table_insert.style), None); let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::existing(table, choice.handle); self.active_modal = None; self.reset_modal_geometry(); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); return Task::none(); } let parse_usize = |value: &str| value.trim().parse::().ok().filter(|value| *value > 0); let parse_number = |value: &str| value.trim().parse::().ok().filter(|value| value.is_finite() && *value > 0.0); let (Some(columns), Some(data_rows), Some(column_width), Some(row_height)) = ( parse_usize(&self.table_insert.columns), parse_usize(&self.table_insert.data_rows), parse_number(&self.table_insert.column_width), parse_number(&self.table_insert.row_height), ) else { self.table_insert.error = crate::t!("Columns, rows, width, and height must be positive values.").into_owned(); return Task::none(); }; let doc = &self.tabs[i].scene.document; let style = doc.objects.iter().find_map(|(handle, object)| match object { ObjectType::TableStyle(style) if style.name.eq_ignore_ascii_case(&self.table_insert.style) => Some((*handle, style)), _ => None, }); let multiplier = self.tabs[i].scene.creation_annotation_multiplier(); let command = crate::modules::annotate::table_cmd::TableCommand::configured( style, multiplier, columns, data_rows, column_width, row_height, self.table_insert.insertion == TableInsertion::Window, self.table_insert.preview, [ &self.table_insert.first_row_style, &self.table_insert.second_row_style, &self.table_insert.other_row_style, ], ); self.active_modal = None; self.reset_modal_geometry(); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); Task::none() } pub(super) fn on_data_link_new(&mut self) -> Task { let mut n = 1usize; loop { let name = format!("Data Link {n}"); if !self.data_link_manager.links.iter().any(|link| link.name.eq_ignore_ascii_case(&name)) { self.data_link_manager.name = name; break; } n += 1; } self.data_link_manager.path.clear(); self.data_link_manager.path_type = LinkPathType::Full; self.data_link_manager.editing_handle = None; self.data_link_manager.editing = true; self.data_link_manager.range_kind = LinkRange::EntireSheet; self.data_link_manager.range.clear(); self.data_link_manager.sheets.clear(); self.data_link_manager.sheet.clear(); self.data_link_manager.named_ranges.clear(); self.data_link_manager.allow_write = false; self.data_link_manager.use_source_formatting = true; self.data_link_manager.update_source_formatting = true; self.data_link_manager.insert_table = false; self.data_link_manager.more_options = false; self.data_link_manager.status.clear(); Task::none() } pub(super) fn on_data_link_select(&mut self, handle: Handle) -> Task { self.refresh_data_link_manager(Some(handle)); Task::none() } pub(super) fn on_data_link_edit(&mut self) -> Task { let Some(choice) = self.data_link_manager.selected.clone() else { return Task::none(); }; let Some(link) = data_link(&self.tabs[self.active_tab].scene.document, choice.handle) else { return Task::none(); }; self.data_link_manager.editing = true; self.data_link_manager.editing_handle = Some(choice.handle); self.data_link_manager.name = link_display_name(link); let path = resolve_link_path(&self.tabs[self.active_tab].scene.document, link); self.data_link_manager.path = path.to_string_lossy().into_owned(); self.data_link_manager.path_type = match link.path_option { 2 => LinkPathType::Relative, 3 => LinkPathType::FileName, _ => LinkPathType::Full }; let (sheet, range_kind, range) = link_selection(link); let (sheets, named_ranges) = workbook_metadata(&path).unwrap_or_default(); self.data_link_manager.sheets = sheets; self.data_link_manager.named_ranges = named_ranges; self.data_link_manager.sheet = sheet .filter(|value| self.data_link_manager.sheets.iter().any(|candidate| candidate == value)) .or_else(|| self.data_link_manager.sheets.first().cloned()) .unwrap_or_default(); self.data_link_manager.range = range.unwrap_or_default(); self.data_link_manager.range_kind = range_kind; self.data_link_manager.allow_write = link.option & 1 != 0; self.data_link_manager.use_source_formatting = link.flags & 1 != 0; self.data_link_manager.update_source_formatting = link.flags & 2 != 0; self.data_link_manager.status.clear(); Task::none() } pub(super) fn on_data_link_field(&mut self, field: DataLinkField) -> Task { match field { DataLinkField::Name(value) => self.data_link_manager.name = value, DataLinkField::Path(value) => self.data_link_manager.path = value, DataLinkField::PathType(value) => self.data_link_manager.path_type = value, DataLinkField::RangeKind(value) => self.data_link_manager.range_kind = value, DataLinkField::Range(value) => self.data_link_manager.range = value, DataLinkField::Sheet(value) => self.data_link_manager.sheet = value, DataLinkField::AllowWrite(value) => self.data_link_manager.allow_write = value, DataLinkField::UseSourceFormatting(value) => self.data_link_manager.use_source_formatting = value, DataLinkField::UpdateSourceFormatting(value) => self.data_link_manager.update_source_formatting = value, DataLinkField::InsertTable(value) => self.data_link_manager.insert_table = value, DataLinkField::MoreOptions(value) => self.data_link_manager.more_options = value, } self.data_link_manager.status.clear(); Task::none() } pub(super) fn on_data_link_browse(&mut self) -> Task { Task::perform( async { let Some(handle) = crate::sys::file_dialog() .set_title(crate::t!("Choose a Spreadsheet File").as_ref()) .add_filter(crate::t!("Spreadsheet Files").as_ref(), &["csv", "txt", "xls", "xlsx", "xlsb", "ods"]) .add_filter(crate::t!("All Files").as_ref(), &["*"]) .pick_file() .await else { return None; }; let path = crate::sys::handle_path(&handle); // The browser forgets the file the moment the pick resolves: // keep its bytes so preview, save, and later re-opens read // them instead of asking for the picker again. #[cfg(target_arch = "wasm32")] { let fresh = register_picked_source( &path.to_string_lossy(), Arc::new(handle.read().await), ); if !fresh { return None; } } Some(path) }, Message::DataLinkBrowseResult, ) } pub(super) fn on_data_link_browse_result(&mut self, path: Option) -> Task { #[cfg(target_arch = "wasm32")] if let Some(name) = take_last_collision().take() { self.command_line.push_warning( crate::tf!( "\"{name}\" is already loaded from another folder with different contents; re-picking it was ignored to keep the existing link." ) .as_ref(), ); } if let Some(path) = path { self.data_link_manager.path = path.to_string_lossy().into_owned(); let (sheets, named_ranges) = workbook_metadata(&path).unwrap_or_default(); self.data_link_manager.sheets = sheets; self.data_link_manager.named_ranges = named_ranges; self.data_link_manager.sheet = self .data_link_manager .sheets .first() .cloned() .unwrap_or_default(); if self.data_link_manager.range_kind == LinkRange::NamedRange { self.data_link_manager.range = self .data_link_manager .named_ranges .first() .cloned() .unwrap_or_default(); } self.data_link_manager.preview = read_tabular_file( &path, (!self.data_link_manager.sheet.is_empty()) .then_some(self.data_link_manager.sheet.as_str()), self.data_link_manager.range_kind, (!self.data_link_manager.range.is_empty()) .then_some(self.data_link_manager.range.as_str()), ) .unwrap_or_default(); } Task::none() } pub(super) fn on_data_link_save(&mut self) -> Task { let i = self.active_tab; let name = self.data_link_manager.name.trim().to_string(); let input_path = PathBuf::from(self.data_link_manager.path.trim()); if name.is_empty() { self.data_link_manager.status = crate::t!("Enter a data link name.").into_owned(); return Task::none(); } if self.data_link_manager.links.iter().any(|link| link.name.eq_ignore_ascii_case(&name) && Some(link.handle) != self.data_link_manager.editing_handle) { self.data_link_manager.status = crate::t!("A data link with that name already exists.").into_owned(); return Task::none(); } let range = (self.data_link_manager.range_kind != LinkRange::EntireSheet) .then(|| self.data_link_manager.range.trim().to_string()) .filter(|value| !value.is_empty()); let preview = match read_tabular_file( &input_path, (!self.data_link_manager.sheet.is_empty()) .then_some(self.data_link_manager.sheet.as_str()), self.data_link_manager.range_kind, range.as_deref(), ) { Ok(rows) => rows, Err(error) => { self.data_link_manager.status = error; return Task::none(); } }; let stored_path = match self.data_link_manager.path_type { LinkPathType::Full => std::fs::canonicalize(&input_path).unwrap_or(input_path.clone()), LinkPathType::Relative => self.tabs[i].scene.document.source_path.as_deref().map(Path::new).and_then(Path::parent).and_then(|parent| input_path.strip_prefix(parent).ok()).map(Path::to_path_buf).unwrap_or(input_path.clone()), LinkPathType::FileName => input_path.file_name().map(PathBuf::from).unwrap_or(input_path.clone()), }; self.push_undo_snapshot(i, "DATALINK"); let handle = if let Some(handle) = self.data_link_manager.editing_handle { if let Some(link) = data_link_mut(&mut self.tabs[i].scene.document, handle) { link.description = name; link.tooltip = input_path.to_string_lossy().into_owned(); link.connection_string = stored_path.to_string_lossy().into_owned(); link.path_option = match self.data_link_manager.path_type { LinkPathType::Full => 1, LinkPathType::Relative => 2, LinkPathType::FileName => 3 }; link.option = i32::from(self.data_link_manager.allow_write); link.flags = i32::from(self.data_link_manager.use_source_formatting) | (i32::from(self.data_link_manager.update_source_formatting) << 1); link.status_flags = 1; link.update_status = "Linked".into(); link.custom_data = link_custom_data( &self.data_link_manager.sheet, self.data_link_manager.range_kind, range.as_deref(), ); } handle } else { let handle = self.tabs[i].scene.document.allocate_handle(); let mut object = ClassObject::new(ClassObjectData::DataLink(DataLink { data_adapter: if input_path.extension().and_then(|value| value.to_str()).is_some_and(|value| value.eq_ignore_ascii_case("csv") || value.eq_ignore_ascii_case("txt")) { "CSV".into() } else { "Spreadsheet".into() }, description: name, tooltip: input_path.to_string_lossy().into_owned(), connection_string: stored_path.to_string_lossy().into_owned(), option: i32::from(self.data_link_manager.allow_write), flags: i32::from(self.data_link_manager.use_source_formatting) | (i32::from(self.data_link_manager.update_source_formatting) << 1), path_option: match self.data_link_manager.path_type { LinkPathType::Full => 1, LinkPathType::Relative => 2, LinkPathType::FileName => 3 }, status_flags: 1, update_status: "Linked".into(), custom_data: link_custom_data( &self.data_link_manager.sheet, self.data_link_manager.range_kind, range.as_deref(), ), ..DataLink::default() })); object.handle = handle; self.tabs[i].scene.document.objects.insert(handle, ObjectType::ClassObject(object)); handle }; self.tabs[i].dirty = true; self.data_link_manager.editing = false; self.data_link_manager.editing_handle = None; self.data_link_manager.preview = preview.clone(); self.refresh_data_link_manager(Some(handle)); if self.data_link_manager.insert_table { let style = table_style_handle(&self.tabs[i].scene.document, &self.tabs[i].scene.document.header.current_table_style_name); let table = build_table(&preview, style, None); let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::existing(table, handle); self.active_modal = None; self.reset_modal_geometry(); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } Task::none() } pub(super) fn on_data_link_delete(&mut self) -> Task { let Some(handle) = self.data_link_manager.selected.as_ref().map(|value| value.handle) else { return Task::none(); }; let i = self.active_tab; self.push_undo_snapshot(i, "DATALINK"); for entity in self.tabs[i].scene.document.entities_mut() { if let EntityType::Table(table) = entity { for row in &mut table.rows { for cell in &mut row.cells { if cell.data_link_handle == Some(handle) { cell.data_link_handle = None; cell.has_linked_data = false; cell.data_link_rows = 0; cell.data_link_columns = 0; cell.state.remove(acadrust::entities::table::CellStateFlags::LINKED | acadrust::entities::table::CellStateFlags::CONTENT_LOCKED | acadrust::entities::table::CellStateFlags::FORMAT_LOCKED); } } } } } self.tabs[i].scene.document.objects.remove(&handle); self.tabs[i].dirty = true; self.data_link_manager.selected = None; self.refresh_data_link_manager(None); self.data_link_manager.status = crate::t!("Data link deleted; existing table values were retained.").into_owned(); Task::none() } pub(super) fn on_data_link_insert(&mut self) -> Task { let Some(choice) = self.data_link_manager.selected.clone() else { return Task::none(); }; let i = self.active_tab; let doc = &self.tabs[i].scene.document; let Some(link) = data_link(doc, choice.handle) else { return Task::none(); }; let rows = match read_link_rows(doc, link) { Ok(rows) => rows, Err(error) => { self.data_link_manager.status = error; return Task::none(); } }; let style = table_style_handle(doc, &doc.header.current_table_style_name); let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::existing(build_table(&rows, style, None), choice.handle); self.active_modal = None; self.reset_modal_geometry(); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); Task::none() } pub(super) fn on_data_link_close(&mut self) -> Task { if self.data_link_parent_table { self.data_link_parent_table = false; let links = data_link_choices(&self.tabs[self.active_tab].scene.document); self.table_insert.links = links; self.table_insert.link = self.table_insert.links.first().cloned(); self.reset_annotation_modal(ModalKind::InsertTable); } else { self.active_modal = None; self.reset_modal_geometry(); self.ribbon.deactivate_tool(); } Task::none() } pub(super) fn on_data_extraction_field(&mut self, field: DataExtractionField) -> Task { match field { DataExtractionField::Begin(value) => self.data_extraction.begin = value, DataExtractionField::SettingsPath(value) => self.data_extraction.settings_path = value, DataExtractionField::Source(value) => self.data_extraction.source = value, DataExtractionField::IncludeCurrent(value) => self.data_extraction.include_current = value, DataExtractionField::IncludeSubfolders(value) => self.data_extraction.include_subfolders = value, DataExtractionField::Object(index, value) => if let Some(item) = self.data_extraction.objects.get_mut(index) { item.checked = value; }, DataExtractionField::Property(index, value) => if let Some(item) = self.data_extraction.properties.get_mut(index) { item.checked = value; }, DataExtractionField::Category(category, value) => if value { self.data_extraction.categories.insert(category); } else { self.data_extraction.categories.remove(&category); }, DataExtractionField::CombineIdentical(value) => self.data_extraction.combine_identical = value, DataExtractionField::ShowCount(value) => self.data_extraction.show_count = value, DataExtractionField::ShowName(value) => self.data_extraction.show_name = value, DataExtractionField::OutputTable(value) => self.data_extraction.output_table = value, DataExtractionField::OutputFile(value) => self.data_extraction.output_file = value, DataExtractionField::OutputPath(value) => self.data_extraction.output_path = value, DataExtractionField::TableStyle(value) => self.data_extraction.table_style = value, DataExtractionField::TableTitle(value) => self.data_extraction.table_title = value, } self.data_extraction.error.clear(); if self.data_extraction.page == ExtractionPage::Refine { self.update_extraction_preview(); } Task::none() } fn refresh_extraction_objects(&mut self) -> Result<(), String> { let state = self.data_extraction.clone(); let selected = state .selection_handles .iter() .copied() .collect::>(); let mut counts = BTreeMap::new(); let current = &self.tabs[self.active_tab].scene.document; match state.source { ExtractionSource::CurrentDrawing => collect_type_counts(current, None, &mut counts), ExtractionSource::CurrentSelection => { collect_type_counts(current, Some(&selected), &mut counts) } ExtractionSource::DrawingsAndFolders => { if state.include_current { collect_type_counts(current, None, &mut counts); } for (_, document) in extraction_documents_for(&state)? { collect_type_counts(&document, None, &mut counts); } } } let previous = self .data_extraction .objects .iter() .map(|item| (item.name.to_ascii_lowercase(), item.checked)) .collect::>(); self.data_extraction.objects = counts .into_iter() .map(|(name, count)| crate::ui::window::annotation_data::ExtractionObject { checked: previous .get(&name.to_ascii_lowercase()) .copied() .unwrap_or(true), name, count, }) .collect(); Ok(()) } fn update_extraction_preview(&mut self) { match extraction_preview(&self.data_extraction, &self.tabs[self.active_tab].scene.document) { Ok(rows) => { self.data_extraction.preview = rows; self.data_extraction.error.clear(); } Err(error) => self.data_extraction.error = error, } } pub(super) fn on_data_extraction_back(&mut self) -> Task { self.data_extraction.page = match self.data_extraction.page { ExtractionPage::Begin => ExtractionPage::Begin, ExtractionPage::Source => ExtractionPage::Begin, ExtractionPage::Objects => ExtractionPage::Source, ExtractionPage::Properties => ExtractionPage::Objects, ExtractionPage::Refine => ExtractionPage::Properties, ExtractionPage::Output => ExtractionPage::Refine, ExtractionPage::TableStyle => ExtractionPage::Output, ExtractionPage::Finish if self.data_extraction.output_table => ExtractionPage::TableStyle, ExtractionPage::Finish => ExtractionPage::Output, }; self.data_extraction.error.clear(); Task::none() } pub(super) fn on_data_extraction_next(&mut self) -> Task { self.data_extraction.error.clear(); match self.data_extraction.page { ExtractionPage::Begin => { if self.data_extraction.settings_path.trim().is_empty() { return self.on_data_extraction_browse_settings(); } if self.data_extraction.begin != ExtractionBegin::New { if let Err(error) = load_extraction_settings(&mut self.data_extraction) { self.data_extraction.error = error; return Task::none(); } } self.data_extraction.page = ExtractionPage::Source; } ExtractionPage::Source => { if self.data_extraction.source == ExtractionSource::CurrentSelection && self.data_extraction.selection_handles.is_empty() { self.data_extraction.error = crate::t!("No objects were selected when the wizard opened.").into_owned(); return Task::none(); } if self.data_extraction.source == ExtractionSource::DrawingsAndFolders && !self.data_extraction.include_current && self.data_extraction.source_files.is_empty() { self.data_extraction.error = crate::t!("Add a drawing or include the current drawing.").into_owned(); return Task::none(); } if let Err(error) = self.refresh_extraction_objects() { self.data_extraction.error = error; return Task::none(); } if self.data_extraction.objects.is_empty() { self.data_extraction.error = crate::t!("No extractable objects were found in the selected source.").into_owned(); return Task::none(); } self.data_extraction.page = ExtractionPage::Objects; } ExtractionPage::Objects => { if !self.data_extraction.objects.iter().any(|value| value.checked) { self.data_extraction.error = crate::t!("Select at least one object type.").into_owned(); return Task::none(); } self.data_extraction.page = ExtractionPage::Properties; } ExtractionPage::Properties => { if !self.data_extraction.properties.iter().any(|value| value.checked) { self.data_extraction.error = crate::t!("Select at least one property.").into_owned(); return Task::none(); } self.data_extraction.page = ExtractionPage::Refine; self.update_extraction_preview(); } ExtractionPage::Refine => { self.update_extraction_preview(); if self.data_extraction.error.is_empty() { self.data_extraction.page = ExtractionPage::Output; } } ExtractionPage::Output => { if !self.data_extraction.output_table && !self.data_extraction.output_file { self.data_extraction.error = crate::t!("Choose at least one output.").into_owned(); return Task::none(); } if self.data_extraction.output_file && self.data_extraction.output_path.trim().is_empty() { return self.on_data_extraction_browse_output(); } self.data_extraction.page = if self.data_extraction.output_table { ExtractionPage::TableStyle } else { ExtractionPage::Finish }; } ExtractionPage::TableStyle => self.data_extraction.page = ExtractionPage::Finish, ExtractionPage::Finish => return self.on_data_extraction_finish(), } Task::none() } pub(super) fn on_data_extraction_browse_settings(&mut self) -> Task { let save = self.data_extraction.begin == ExtractionBegin::New; Task::perform(async move { let dialog = crate::sys::file_dialog().set_title(crate::t!("Data Extraction Settings").as_ref()).add_filter(crate::t!("Data Extraction Settings").as_ref(), &["dxex"]).add_filter(crate::t!("All Files").as_ref(), &["*"]); let handle = if save { dialog.set_file_name("extraction.dxex").save_file().await } else { dialog.pick_file().await }; let Some(handle) = handle else { return None; }; let path = crate::sys::handle_path(&handle); // Loading settings in the browser: the picked .dxex has no path // to read back from, so its bytes are kept for the load that // follows this message. #[cfg(target_arch = "wasm32")] if !save { let fresh = register_picked_source( &path.to_string_lossy(), Arc::new(handle.read().await), ); if !fresh { return None; } } Some(path) }, Message::DataExtractionBrowseSettingsResult) } pub(super) fn on_data_extraction_browse_settings_result(&mut self, path: Option) -> Task { if let Some(path) = path { self.data_extraction.settings_path = path.to_string_lossy().into_owned(); if self.data_extraction.begin != ExtractionBegin::New { if let Err(error) = load_extraction_settings(&mut self.data_extraction) { self.data_extraction.error = error; return Task::none(); } } self.data_extraction.page = ExtractionPage::Source; } Task::none() } #[cfg(not(target_arch = "wasm32"))] pub(super) fn on_data_extraction_add_drawings(&mut self) -> Task { Task::perform(async { Ok(crate::sys::file_dialog() .set_title(crate::t!("Add Drawings").as_ref()) .add_filter(crate::t!("CAD Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"]) .add_filter(crate::t!("All Files").as_ref(), &["*"]) .pick_files() .await .unwrap_or_default() .into_iter() .map(|handle| crate::sys::handle_path(&handle)) .collect()) }, Message::DataExtractionAddDrawingsResult) } /// Web: the picked drawings have no paths to re-open later, so each one is /// parsed on the shared worker right here and the document registered /// under its name — the pipeline then reads the registry instead of the /// filesystem. The first file that fails to parse aborts the add with the /// same `name: error` shape the native refresh reports. #[cfg(target_arch = "wasm32")] pub(super) fn on_data_extraction_add_drawings(&mut self) -> Task { Task::perform( async { let handles = crate::sys::file_dialog() .set_title(crate::t!("Add Drawings").as_ref()) .add_filter(crate::t!("CAD Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"]) .add_filter(crate::t!("All Files").as_ref(), &["*"]) .pick_files() .await .unwrap_or_default(); let mut paths = Vec::new(); for handle in handles { let name = handle.file_name(); let bytes = handle.read().await; let document = crate::io::parse_drawing_bytes_web(&name, &bytes) .await .map_err(|error| format!("{}: {error}", name))?; register_extraction_document(&name, document); paths.push(PathBuf::from(name)); } Ok(paths) }, Message::DataExtractionAddDrawingsResult, ) } pub(super) fn on_data_extraction_add_drawings_result(&mut self, paths: Vec) -> Task { for path in paths { let text = path.to_string_lossy().into_owned(); if !self.data_extraction.source_files.iter().any(|value| value.eq_ignore_ascii_case(&text)) { self.data_extraction.source_files.push(text); } } Task::none() } #[cfg(not(target_arch = "wasm32"))] pub(super) fn on_data_extraction_add_folder(&mut self) -> Task { Task::perform( async { crate::sys::file_dialog() .set_title(crate::t!("Add Drawing Folder").as_ref()) .pick_folder() .await .map(|handle| crate::sys::handle_path(&handle)) }, Message::DataExtractionAddFolderResult, ) } #[cfg(target_arch = "wasm32")] pub(super) fn on_data_extraction_add_folder(&mut self) -> Task { Task::none() } pub(super) fn on_data_extraction_add_folder_result( &mut self, path: Option, ) -> Task { if let Some(path) = path { let text = path.to_string_lossy().into_owned(); if !self .data_extraction .source_files .iter() .any(|value| value.eq_ignore_ascii_case(&text)) { self.data_extraction.source_files.push(text); } } Task::none() } pub(super) fn on_data_extraction_browse_output(&mut self) -> Task { Task::perform(async { crate::sys::file_dialog() .set_title(crate::t!("Save Data Extraction").as_ref()) .set_file_name("extraction.csv") .add_filter(crate::t!("CSV").as_ref(), &["csv"]) .add_filter(crate::t!("Tab-separated text").as_ref(), &["txt"]) .add_filter(crate::t!("All Files").as_ref(), &["*"]) .save_file() .await .map(|handle| crate::sys::handle_path(&handle)) }, Message::DataExtractionBrowseOutputResult) } pub(super) fn on_data_extraction_browse_output_result(&mut self, path: Option) -> Task { if let Some(path) = path { self.data_extraction.output_path = path.to_string_lossy().into_owned(); self.data_extraction.output_file = true; if self.data_extraction.page == ExtractionPage::Output { self.data_extraction.page = if self.data_extraction.output_table { ExtractionPage::TableStyle } else { ExtractionPage::Finish }; } } Task::none() } pub(super) fn on_data_extraction_finish(&mut self) -> Task { self.update_extraction_preview(); if !self.data_extraction.error.is_empty() { return Task::none(); } if let Err(error) = save_extraction_settings(&self.data_extraction) { self.data_extraction.error = error; return Task::none(); } let rows = self.data_extraction.preview.clone(); if self.data_extraction.output_file { let path = PathBuf::from(self.data_extraction.output_path.trim()); let text = if path.extension().and_then(|value| value.to_str()).is_some_and(|value| value.eq_ignore_ascii_case("txt")) { rows.iter().map(|row| row.join("\t")).collect::>().join("\n") + "\n" } else { rows_to_csv(&rows) }; // Web output mirrors the native CSV/TXT write (there is no xlsx // writer on either target): the browser gets the same bytes as a // download under the picked name. #[cfg(target_arch = "wasm32")] { let name = path .file_name() .map(|value| value.to_string_lossy().into_owned()) .unwrap_or_else(|| "extraction.csv".into()); crate::sys::download_bytes(&name, text.as_bytes()); } #[cfg(not(target_arch = "wasm32"))] if let Err(error) = std::fs::write(&path, text) { self.data_extraction.error = error.to_string(); return Task::none(); } } let i = self.active_tab; if self.data_extraction.output_table { let style = table_style_handle(&self.tabs[i].scene.document, &self.data_extraction.table_style); let table = build_table(&rows, style, Some(&self.data_extraction.table_title)); let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::unlinked(table); self.active_modal = None; self.reset_modal_geometry(); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } else { self.active_modal = None; self.reset_modal_geometry(); self.ribbon.deactivate_tool(); self.command_line.push_output(crate::t!("DATAEXTRACTION: external file created.").as_ref()); } Task::none() } } #[cfg(test)] mod tests { use super::*; #[test] fn cell_ranges_are_normalized_and_missing_cells_are_blank() { let rows = vec![ vec!["a".into(), "b".into(), "c".into()], vec!["d".into()], ]; assert_eq!( crop_range(rows, "C2:A1").unwrap(), vec![ vec!["a".to_string(), "b".to_string(), "c".to_string()], vec!["d".to_string(), String::new(), String::new()], ] ); } #[test] fn named_range_targets_accept_quoted_sheet_names() { assert_eq!( named_range_target("='Sheet One'!$A$1:$D$10"), Some(("Sheet One".into(), "A1:D10".into())) ); } #[test] fn csv_links_apply_the_selected_cell_range() { let path = std::env::temp_dir().join(format!( "ocs-data-link-{}-{}.csv", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::write(&path, "a,b,c\nd,e,f\n").unwrap(); let rows = read_tabular_file(&path, None, LinkRange::CellRange, Some("B1:C2")); let _ = std::fs::remove_file(path); assert_eq!( rows.unwrap(), vec![ vec!["b".to_string(), "c".to_string()], vec!["e".to_string(), "f".to_string()], ] ); } #[test] fn csv_links_read_from_registered_bytes() { let name = format!("ocs-registry-{}-{}.csv", std::process::id(), line!()); register_picked_source(&name, Arc::new(b"a,b,c\nd,e,f\n".to_vec())); let rows = read_tabular_file(Path::new(&name), None, LinkRange::CellRange, Some("B1:C2")); assert_eq!( rows.unwrap(), vec![ vec!["b".to_string(), "c".to_string()], vec!["e".to_string(), "f".to_string()], ] ); } #[test] fn registered_bytes_match_by_file_name() { let key = format!("ocs-match-{}-{}.xlsx", std::process::id(), line!()); register_picked_source( &format!(r"C:\drawings\{key}"), Arc::new(Vec::new()), ); // Exact key… assert!(picked_source_bytes(&format!(r"C:\drawings\{key}")).is_some()); // …same file under a differently-cased bare name (the web stores only // the name, links may store the author's path)… assert!(picked_source_bytes(&key.to_ascii_uppercase()).is_some()); // …and not another file. assert!(picked_source_bytes("ocs-match-other.xlsx").is_none()); } #[test] fn registered_extraction_documents_match_by_file_name() { let key = format!("ocs-doc-{}-{}.dwg", std::process::id(), line!()); register_extraction_document(&key, CadDocument::new()); assert!(extraction_document(&key.to_ascii_uppercase()).is_some()); assert!(extraction_document("ocs-doc-other.dwg").is_none()); } #[test] fn workbook_metadata_bytes_rejects_non_workbooks() { assert!(workbook_metadata_bytes(b"not a workbook").is_err()); } }