Files
OneCAD-Draft/src/app/annotation_data.rs
T
Karim Jerbi a0308dfa88 fix(data-link): clamp cell-range column window to the data width
Bug:
crop_range bounded the row axis with skip/take over the file's real rows
but left the column axis unbounded: c0..=c1 collects one String per
column, and a range like A1:ZZZZZZ1 parses to roughly 308 million
columns, causing a capacity overflow while allocating a row.

Reproduce:
1. Data Link -> pick any CSV/xlsx, set Cell Range to A1:ZZZZZZ1 (or any
   range whose parsed column index is enormous).
2. Before: "capacity overflow" abort while building the preview.
3. After: the window is cropped to the file's widest row; columns past
   the data yield nothing beyond the data width.

Fix:
c1 is clamped to widest.saturating_sub(1) before the range is collected,
so the span is provably at most the file's own data - allocation is
O(file). Rows outside the file still come back empty (in-range blank
padding inside the data width is preserved), and c0 > c1 naturally
yields an empty window. Regression tests cover the 13-Z capacity-overflow
input and a 6-Z range that now crops.
2026-09-30 19:15:49 +01:00

1482 lines
65 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> {
let mut styles: Vec<String> = 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<DataLinkChoice> {
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::<Vec<_>>();
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<usize> {
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::<usize>().ok()?.checked_sub(1)?;
Some((row, column))
}
fn crop_range(rows: Vec<Vec<String>>, range: &str) -> Result<Vec<Vec<String>>, 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<String>, Vec<String>), 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<Vec<Vec<String>>, 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<Handle> {
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<String>], style: Option<Handle>, 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<String>, LinkRange, Option<String>) {
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<DataLinkCustomData> {
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<Vec<Vec<String>>, 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<Vec<Vec<String>>, 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<PathBuf, String> {
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<Handle>>,
allowed_types: &BTreeSet<String>,
out: &mut Vec<ExtractionRecord>,
) {
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<PathBuf>,
) -> 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(())
}
fn extraction_drawing_paths(state: &DataExtractionState) -> Result<Vec<PathBuf>, 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<Handle>>,
counts: &mut BTreeMap<String, usize>,
) {
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<Vec<Vec<String>>, String> {
let allowed_types = state.objects.iter().filter(|value| value.checked).map(|value| value.name.clone()).collect::<BTreeSet<_>>();
let selected = state.selection_handles.iter().copied().collect::<BTreeSet<_>>();
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::<Vec<_>>();
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<Vec<String>, 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>]) -> String {
let mut csv = String::new();
for row in rows {
csv.push_str(&row.iter().map(|value| csv_escape(value)).collect::<Vec<_>>().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::<Vec<_>>();
let properties = state.properties.iter().filter(|value| value.checked).map(|value| value.key.clone()).collect::<Vec<_>>();
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::<BTreeSet<_>>()).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::<BTreeSet<_>>()).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<Handle>) {
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::<Vec<_>>();
let mut counts = BTreeMap::<String, usize>::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<Message> {
self.table_insert.style = style;
Task::none()
}
pub(super) fn on_table_insert_field(&mut self, field: TableInsertField) -> Task<Message> {
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<Message> {
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::<usize>().ok().filter(|value| *value > 0);
let parse_number = |value: &str| value.trim().parse::<f64>().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<Message> {
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<Message> {
self.refresh_data_link_manager(Some(handle));
Task::none()
}
pub(super) fn on_data_link_edit(&mut self) -> Task<Message> {
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<Message> {
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<Message> {
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<PathBuf>) -> Task<Message> {
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<Message> {
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<Message> {
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<Message> {
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<Message> {
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<Message> {
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::<BTreeSet<_>>();
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::<BTreeMap<_, _>>();
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<Message> {
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<Message> {
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<Message> {
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<PathBuf>) -> Task<Message> {
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<Message> {
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<PathBuf>) -> Task<Message> {
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<Message> {
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<Message> {
Task::none()
}
pub(super) fn on_data_extraction_add_folder_result(
&mut self,
path: Option<PathBuf>,
) -> Task<Message> {
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<Message> {
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<PathBuf>) -> Task<Message> {
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<Message> {
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::<Vec<_>>().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::<String>() 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()]]
);
}
}