use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use codec::entities::{table::CellRange, TableBuilder}; use codec::objects::{ClassObject, ClassObjectData, DataLink, DataLinkCustomData, ObjectType}; use codec::types::{Handle, Vector3}; use codec::{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, } 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); Some(DataLinkChoice { handle: *handle, name: link_display_name(link), path: path.to_string_lossy().into_owned(), valid: path.is_file(), }) } _ => 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)); // The row axis is bounded by the file (skip/take over real rows); the // column axis was not — `A1:ZZZZZZ1` parses fine and then collected one // String per column (~308 M slots per row, OOM). Columns past the widest // row are empty in every row, so clamp the window to the data width. let widest = rows.iter().map(Vec::len).max().unwrap_or(0); let c1 = c1.min(widest.saturating_sub(1)); 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())); } 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)) } 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)) } 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()); } let text = std::fs::read_to_string(path).map_err(|error| error.to_string())?; ( crate::app::commands::display::parse_csv_table(&text), (range_kind == LinkRange::CellRange) .then_some(range_value) .flatten() .map(str::to_string), ) } else { use calamine::{open_workbook_auto, Reader}; let mut workbook = open_workbook_auto(path).map_err(|error| error.to_string())?; 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())?; ( data.rows() .map(|row| row.iter().map(ToString::to_string).collect()) .collect(), (!cell_range.is_empty()).then_some(cell_range), ) }; 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>) -> codec::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), }); } } 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")) } 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::t!("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(()) } 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) } 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 source in extraction_drawing_paths(state)? { let doc = crate::io::load_file(&source).map_err(|error| format!("{}: {error}", source.display()))?; collect_records(&doc, 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())?; std::fs::write(&state.settings_path, text).map_err(|error| error.to_string()) } fn load_extraction_settings(state: &mut DataExtractionState) -> Result<(), String> { let text = std::fs::read_to_string(&state.settings_path).map_err(|error| error.to_string())?; 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 { 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 .map(|handle| crate::sys::handle_path(&handle)) }, Message::DataLinkBrowseResult, ) } pub(super) fn on_data_link_browse_result(&mut self, path: Option) -> Task { 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(codec::entities::table::CellStateFlags::LINKED | codec::entities::table::CellStateFlags::CONTENT_LOCKED | codec::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 path in extraction_drawing_paths(&state)? { let doc = crate::io::load_file(&path) .map_err(|error| format!("{}: {error}", path.display()))?; collect_type_counts(&doc, 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(), &["*"]); if save { dialog.set_file_name("extraction.dxex").save_file().await.map(|handle| crate::sys::handle_path(&handle)) } else { dialog.pick_file().await.map(|handle| crate::sys::handle_path(&handle)) } }, 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() } pub(super) fn on_data_extraction_add_drawings(&mut self) -> Task { Task::perform(async { 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) } 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) }; 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()], ] ); } /// `column_index` only checks `checked_mul(26)`, so a range like /// `A1:ZZZZZZ1` parses fine — and `(c0..=c1)` then collects one `String` /// per column: ~308 M slots (~7 GB) for 6 Z's, and a `capacity overflow` /// panic at 13 Z's. Columns past the widest row are empty in *every* /// row, so the window must be clamped to the data width. #[test] fn crop_range_bounds_oversized_column_spans() { let rows = vec![vec!["x".to_string()]]; // 13 Z's: pre-fix, size_hint × size_of::() exceeds isize::MAX // and Vec's capacity check panics before a single cell is read. assert_eq!( crop_range(rows.clone(), "A1:ZZZZZZZZZZZZZ1").unwrap(), vec![vec!["x".to_string()]] ); // 6 Z's — the bug-hunt report's repro — stays bounded too. assert_eq!( crop_range(rows, "A1:ZZZZZZ1").unwrap(), vec![vec!["x".to_string()]] ); } }