Files
OneCAD-Draft/src/app/annotation_data.rs
T
Hakan SevenandClaude Opus 5.5 f537aff75c feat: node graph overlay; move to opencadcodec/opencadkernel/opencadgraph
The codec and kernel repositories and crates are renamed opencadcodec and
opencadkernel. Inside OCS they are depended on under short keys, so code
reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node
graph engine). File-format and protocol spellings stay as they were: the
ACIS product id `acadrust`, the ACADRUST_* switches and the plugin
version keys `acadrust_version` / `acadrust_source`.

A button left of MCP on the command line opens a translucent node graph
over the viewport, with a palette of object, input, math, logic, list,
text and geometry nodes added by click or drag. An object node owns
drawing entities and its rows are the Properties panel's own rows for
them: the single-object page is extracted from refresh_properties as
entity_property_sections, values use the control protocol's
property_json, and writes go through set_property_value with a property
target override, so the panel, MCP and the graph share one read/write
path. A list reaching an object row makes one entity per element. Each
graph action is one undo step; the canvas pans and zooms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 07:34:23 +03:00

1455 lines
63 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));
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()],
]
);
}
}