forked from 1b-agent/opencadstudio-upstream
1455 lines
63 KiB
Rust
1455 lines
63 KiB
Rust
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use acadrust::entities::{table::CellRange, TableBuilder};
|
|
use acadrust::objects::{ClassObject, ClassObjectData, DataLink, DataLinkCustomData, ObjectType};
|
|
use acadrust::types::{Handle, Vector3};
|
|
use acadrust::{CadDocument, EntityType};
|
|
use iced::Task;
|
|
|
|
use super::{Message, ModalKind, OpenCADStudio};
|
|
use crate::command::CadCommand;
|
|
use crate::ui::window::annotation_data::{
|
|
DataExtractionField, DataExtractionState, DataLinkChoice, DataLinkField,
|
|
ExtractionBegin, ExtractionPage, ExtractionSource, LinkPathType, LinkRange,
|
|
TableInsertField, TableInsertion, TableSource,
|
|
};
|
|
|
|
#[derive(Clone)]
|
|
struct ExtractionRecord {
|
|
object_type: String,
|
|
handle: String,
|
|
layer: String,
|
|
color: String,
|
|
linetype: String,
|
|
details: String,
|
|
}
|
|
|
|
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));
|
|
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>) -> acadrust::entities::Table {
|
|
let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
|
let title_rows = usize::from(title.is_some_and(|value| !value.trim().is_empty()));
|
|
let mut table = TableBuilder::new(rows.len().max(1) + title_rows, columns)
|
|
.at(Vector3::new(0.0, 0.0, 0.0))
|
|
.row_height(0.5)
|
|
.column_width(2.0)
|
|
.build();
|
|
table.table_style_handle = style;
|
|
if let Some(title) = title.filter(|value| !value.trim().is_empty()) {
|
|
table.set_cell_text(0, 0, title);
|
|
if columns > 1 {
|
|
table.merge_cells(CellRange::new(0, 0, 0, columns - 1));
|
|
}
|
|
}
|
|
for (row_index, row) in rows.iter().enumerate() {
|
|
for (column_index, value) in row.iter().enumerate() {
|
|
table.set_cell_text(row_index + title_rows, column_index, value);
|
|
}
|
|
}
|
|
table
|
|
}
|
|
|
|
fn link_selection(link: &DataLink) -> (Option<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(acadrust::entities::table::CellStateFlags::LINKED | acadrust::entities::table::CellStateFlags::CONTENT_LOCKED | acadrust::entities::table::CellStateFlags::FORMAT_LOCKED);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.tabs[i].scene.document.objects.remove(&handle);
|
|
self.tabs[i].dirty = true;
|
|
self.data_link_manager.selected = None;
|
|
self.refresh_data_link_manager(None);
|
|
self.data_link_manager.status = crate::t!("Data link deleted; existing table values were retained.").into_owned();
|
|
Task::none()
|
|
}
|
|
|
|
pub(super) fn on_data_link_insert(&mut self) -> Task<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()],
|
|
]
|
|
);
|
|
}
|
|
}
|