forked from 1b-agent/opencadstudio-upstream
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.
1482 lines
65 KiB
Rust
1482 lines
65 KiB
Rust
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()]]
|
||
);
|
||
}
|
||
}
|