Files
OneCAD-Draft/src/app/annotation_data.rs
T

2255 lines
88 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
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,
}
// ── In-session picked-file registries (web) ───────────────────────────────
//
// The browser exposes no reusable filesystem path: a picked file's bytes exist
// only while the app keeps them. Both registries below hold those bytes (and
// what was parsed from them) keyed by the reference the drawing or wizard
// carries, following the image-model / PDF-raster pattern. Native builds never
// fill them — the real path stays readable — but the lookup and matching
// logic compiles everywhere so it stays unit-testable.
fn picked_sources() -> &'static Mutex<HashMap<String, Arc<Vec<u8>>>> {
static SOURCES: OnceLock<Mutex<HashMap<String, Arc<Vec<u8>>>>> = OnceLock::new();
SOURCES.get_or_init(|| Mutex::new(HashMap::new()))
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn extraction_documents() -> &'static Mutex<HashMap<String, Arc<CadDocument>>> {
static DOCUMENTS: OnceLock<Mutex<HashMap<String, Arc<CadDocument>>>> = OnceLock::new();
DOCUMENTS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Lower-cased trailing component of a path-style reference. Links and wizard
/// sources carry whatever the author stored, so the file name — not the full
/// path — is the only component a re-picked file can be expected to match.
fn source_name_key(path: &str) -> String {
path.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.to_ascii_lowercase()
}
/// Do two source references name the same file? Compares trailing components
/// case-insensitively (Windows paths are case-insensitive where the link was
/// authored, and the web has no path beyond the name at all).
fn same_source_name(a: &str, b: &str) -> bool {
let (a, b) = (source_name_key(a), source_name_key(b));
!a.is_empty() && a == b
}
/// Keep the bytes of a spreadsheet or settings file chosen through a picker.
/// Re-opening the link in-session re-reads these instead of demanding a new
/// pick; re-picking the same file overwrites the entry.
// Only the web registers sources (native re-reads the real file, so a file
// edited after picking is picked up); tests exercise the registry directly.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
/// Returns false when the registration REPLACED different bytes under the
/// same key — the browser hands back bare file names, so two distinct files
/// named alike collide and every reference to that name would silently read
/// the newer pick. Callers surface that to the user.
pub(crate) fn register_picked_source(path: &str, bytes: Arc<Vec<u8>>) -> bool {
let mut cache = picked_sources()
.lock()
.unwrap_or_else(|error| error.into_inner());
let Some(old) = cache.get(path) else {
cache.insert(path.to_string(), bytes);
return true;
};
if old.as_slice() == bytes.as_slice() {
return true;
}
// Refuse AND keep the old bytes: overwriting would silently retarget
// every reference reading this name. The name is remembered so the
// browse handlers can tell the user why their pick did nothing.
take_last_collision().replace(file_name_of(path));
false
}
/// The name whose re-pick was refused because different bytes are already
/// registered under it — the browse handler reports and clears it.
fn last_collision() -> &'static Mutex<Option<String>> {
static LAST: OnceLock<Mutex<Option<String>>> = OnceLock::new();
LAST.get_or_init(|| Mutex::new(None))
}
fn take_last_collision() -> std::sync::MutexGuard<'static, Option<String>> {
last_collision()
.lock()
.unwrap_or_else(|error| error.into_inner())
}
fn file_name_of(path: &str) -> String {
std::path::Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string())
}
/// Bytes registered for this reference — exact key first, then any entry that
/// names the same file (the web fallback for links whose stored path is the
/// original author's, while the user picked only the bare file).
pub(crate) fn picked_source_bytes(path: &str) -> Option<Arc<Vec<u8>>> {
let cache = picked_sources()
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(bytes) = cache.get(path) {
return Some(Arc::clone(bytes));
}
cache
.iter()
.find(|(key, _)| same_source_name(key, path))
.map(|(_, bytes)| Arc::clone(bytes))
}
/// Keep a source drawing parsed at pick time, keyed by the name the wizard
/// lists. The extraction pipeline reads these on the web instead of calling
/// the path-based `load_file`.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn register_extraction_document(name: &str, document: CadDocument) {
extraction_documents()
.lock()
.unwrap_or_else(|error| error.into_inner())
.insert(name.to_string(), Arc::new(document));
}
/// The parsed drawing registered under this source reference, matched by exact
/// key or by file name.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn extraction_document(path: &str) -> Option<Arc<CadDocument>> {
let cache = extraction_documents()
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(document) = cache.get(path) {
return Some(Arc::clone(document));
}
cache
.iter()
.find(|(key, _)| same_source_name(key, path))
.map(|(_, document)| Arc::clone(document))
}
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);
let path_text = path.to_string_lossy().into_owned();
// The web has no filesystem: a link is valid when its
// picked bytes are still registered in-session.
let valid = path.is_file() || picked_source_bytes(&path_text).is_some();
Some(DataLinkChoice {
handle: *handle,
name: link_display_name(link),
path: path_text,
valid,
})
}
_ => None,
},
_ => None,
})
.collect::<Vec<_>>();
links.sort_by_key(|link| link.name.to_ascii_lowercase());
links
}
fn data_link<'a>(doc: &'a CadDocument, handle: Handle) -> Option<&'a DataLink> {
match doc.objects.get(&handle) {
Some(ObjectType::ClassObject(object)) => match &object.data {
ClassObjectData::DataLink(link) => Some(link),
_ => None,
},
_ => None,
}
}
fn data_link_mut<'a>(doc: &'a mut CadDocument, handle: Handle) -> Option<&'a mut DataLink> {
match doc.objects.get_mut(&handle) {
Some(ObjectType::ClassObject(object)) => match &mut object.data {
ClassObjectData::DataLink(link) => Some(link),
_ => None,
},
_ => None,
}
}
fn column_index(text: &str) -> Option<usize> {
let mut value = 0usize;
let mut any = false;
for byte in text.trim().bytes() {
if !byte.is_ascii_alphabetic() {
break;
}
any = true;
value = value
.checked_mul(26)?
.checked_add((byte.to_ascii_uppercase() - b'A' + 1) as usize)?;
}
any.then_some(value.saturating_sub(1))
}
fn cell_ref(text: &str) -> Option<(usize, usize)> {
let split = text.find(|c: char| c.is_ascii_digit())?;
let column = column_index(&text[..split])?;
let row = text[split..].trim().parse::<usize>().ok()?.checked_sub(1)?;
Some((row, column))
}
fn crop_range(rows: Vec<Vec<String>>, range: &str) -> Result<Vec<Vec<String>>, String> {
let (start, end) = range.split_once(':').unwrap_or((range, range));
let (Some((r0, c0)), Some((r1, c1))) = (cell_ref(start), cell_ref(end)) else {
return Err(crate::t!("Enter a valid cell range such as A1:D10.").into_owned());
};
let (r0, r1) = (r0.min(r1), r0.max(r1));
let (c0, c1) = (c0.min(c1), c0.max(c1));
// The row axis is bounded by the file (skip/take over real rows); the
// column axis was not — `A1:ZZZZZZ1` parses fine and then collected one
// String per column (~308 M slots per row, OOM). Columns past the widest
// row are empty in every row, so clamp the window to the data width.
let widest = rows.iter().map(Vec::len).max().unwrap_or(0);
let c1 = c1.min(widest.saturating_sub(1));
Ok(rows
.into_iter()
.skip(r0)
.take(r1 - r0 + 1)
.map(|row| {
(c0..=c1)
.map(|column| row.get(column).cloned().unwrap_or_default())
.collect()
})
.collect())
}
fn workbook_metadata(path: &Path) -> Result<(Vec<String>, Vec<String>), String> {
let ext = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if ext == "csv" || ext == "txt" {
return Ok((Vec::new(), Vec::new()));
}
if let Some(bytes) = picked_source_bytes(&path.to_string_lossy()) {
return workbook_metadata_bytes(&bytes);
}
if cfg!(target_arch = "wasm32") {
return Err(
crate::t!("Pick the file again to load its contents in the browser.").into_owned(),
);
}
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))
}
/// Sheet and defined-name catalog read straight from bytes (a picked file on
/// the web, where no path exists to reopen).
fn workbook_metadata_bytes(bytes: &[u8]) -> Result<(Vec<String>, Vec<String>), String> {
use calamine::{open_workbook_auto_from_rs, Reader};
let workbook =
open_workbook_auto_from_rs(Cursor::new(bytes)).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))
}
/// Read the text of a CSV/TXT (or settings) source: registered picked bytes
/// first, then the native filesystem. On the web an unregistered path has
/// nothing behind it, so it asks for the pick instead of failing on I/O.
fn read_text_source(path: &Path) -> Result<String, String> {
if let Some(bytes) = picked_source_bytes(&path.to_string_lossy()) {
return Ok(String::from_utf8_lossy(&bytes).into_owned());
}
if cfg!(target_arch = "wasm32") {
return Err(
crate::t!("Pick the file again to load its contents in the browser.").into_owned(),
);
}
std::fs::read_to_string(path).map_err(|error| error.to_string())
}
/// Sheet selection + range resolution shared by the path- and bytes-based
/// workbook readers. Returns the raw rows and the cell range to crop to, if
/// any.
fn workbook_rows<RS: std::io::Read + std::io::Seek>(
workbook: &mut calamine::Sheets<RS>,
sheet: Option<&str>,
range_kind: LinkRange,
range_value: Option<&str>,
) -> Result<(Vec<Vec<String>>, Option<String>), String> {
use calamine::Reader;
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())?;
Ok((
data.rows()
.map(|row| row.iter().map(ToString::to_string).collect())
.collect(),
(!cell_range.is_empty()).then_some(cell_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());
}
(
crate::app::commands::display::parse_csv_table(&read_text_source(path)?),
(range_kind == LinkRange::CellRange)
.then_some(range_value)
.flatten()
.map(str::to_string),
)
} else if let Some(bytes) = picked_source_bytes(&path.to_string_lossy()) {
use calamine::open_workbook_auto_from_rs;
let mut workbook = open_workbook_auto_from_rs(Cursor::new(bytes.as_slice()))
.map_err(|error| error.to_string())?;
workbook_rows(&mut workbook, sheet, range_kind, range_value)?
} else if cfg!(target_arch = "wasm32") {
return Err(
crate::t!("Pick the file again to load its contents in the browser.").into_owned(),
);
} else {
use calamine::open_workbook_auto;
let mut workbook = open_workbook_auto(path).map_err(|error| error.to_string())?;
workbook_rows(&mut workbook, sheet, range_kind, range_value)?
};
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),
});
}
}
#[cfg(not(target_arch = "wasm32"))]
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"))
}
#[cfg(not(target_arch = "wasm32"))]
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::t!("Data source not found: %{path}", path = path.display()).into_owned(),
);
}
for entry in std::fs::read_dir(path).map_err(|error| error.to_string())? {
let entry = entry.map_err(|error| error.to_string())?;
let child = entry.path();
if child.is_file() && is_drawing_file(&child) {
out.push(child);
} else if recursive && child.is_dir() {
collect_drawing_paths(&child, true, out)?;
}
}
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
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)
}
/// Visit the source drawings the extraction pipeline reads beyond the current
/// one. Native loads them from disk one at a time (folders walked
/// recursively), so only one foreign drawing is in memory at once; the web
/// reads the documents parsed at pick time from the in-session registry —
/// every listed source must have been picked, or it asks for the pick again.
fn for_each_extraction_document(
state: &DataExtractionState,
mut visit: impl FnMut(&CadDocument),
) -> Result<(), String> {
#[cfg(target_arch = "wasm32")]
for source in &state.source_files {
let document = extraction_document(source).ok_or_else(|| {
crate::tf!("Drawing not loaded in the browser: {name}.", name = source).into_owned()
})?;
visit(&document);
}
#[cfg(not(target_arch = "wasm32"))]
for source in extraction_drawing_paths(state)? {
let document = crate::io::load_file(&source)
.map_err(|error| format!("{}: {error}", source.display()))?;
visit(&document);
}
Ok(())
}
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_each_extraction_document(state, |document| {
collect_records(document, 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())?;
#[cfg(target_arch = "wasm32")]
{
// No filesystem to write to: hand the settings to the browser as a
// download, mirroring the native write of the same JSON.
let name = Path::new(state.settings_path.trim())
.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "extraction.dxex".into());
crate::sys::download_bytes(&name, text.as_bytes());
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::write(&state.settings_path, text).map_err(|error| error.to_string())
}
}
fn load_extraction_settings(state: &mut DataExtractionState) -> Result<(), String> {
let text = read_text_source(Path::new(state.settings_path.trim()))?;
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()
}
/// Web DATALINK: the picked .csv's bytes arrived. They are registered in
/// the picked-source registry — so `DATALINKUPDATE` re-reads them
/// in-session instead of failing on the absent filesystem — and fed to
/// the same table-insert path the native typed-path command uses.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_datalink_csv_picked(
&mut self,
picked: Option<(String, Vec<u8>)>,
) -> Task<Message> {
let Some((name, bytes)) = picked else {
return Task::none();
};
let text = String::from_utf8_lossy(&bytes).into_owned();
if !register_picked_source(&name, Arc::new(bytes)) {
if let Some(name) = take_last_collision().take() {
self.command_line.push_warning(
crate::tf!(
"\"{name}\" is already loaded with different contents; the existing file was kept."
)
.as_ref(),
);
}
return Task::none();
}
self.place_data_link_table(&text, &name);
Task::none()
}
pub(super) fn on_data_link_browse(&mut self) -> Task<Message> {
Task::perform(
async {
let Some(handle) = 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
else {
return None;
};
let path = crate::sys::handle_path(&handle);
// The browser forgets the file the moment the pick resolves:
// keep its bytes so preview, save, and later re-opens read
// them instead of asking for the picker again.
#[cfg(target_arch = "wasm32")]
{
let fresh = register_picked_source(
&path.to_string_lossy(),
Arc::new(handle.read().await),
);
if !fresh {
return None;
}
}
Some(path)
},
Message::DataLinkBrowseResult,
)
}
pub(super) fn on_data_link_browse_result(&mut self, path: Option<PathBuf>) -> Task<Message> {
#[cfg(target_arch = "wasm32")]
if let Some(name) = take_last_collision().take() {
self.command_line.push_warning(
crate::tf!(
"\"{name}\" is already loaded with different contents; the existing file was kept."
)
.as_ref(),
);
}
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_each_extraction_document(&state, |document| {
collect_type_counts(document, 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(), &["*"]);
let handle = if save {
dialog.set_file_name("extraction.dxex").save_file().await
} else {
dialog.pick_file().await
};
let handle = handle?;
let path = crate::sys::handle_path(&handle);
// Loading settings in the browser: the picked .dxex has no
// path to read back from, so its bytes are kept for the load
// that follows this message.
#[cfg(target_arch = "wasm32")]
if !save {
let fresh = register_picked_source(
&path.to_string_lossy(),
Arc::new(handle.read().await),
);
if !fresh {
return None;
}
}
Some(path)
},
Message::DataExtractionBrowseSettingsResult,
)
}
pub(super) fn on_data_extraction_browse_settings_result(
&mut self,
path: Option<PathBuf>,
) -> Task<Message> {
#[cfg(target_arch = "wasm32")]
if let Some(name) = take_last_collision().take() {
self.data_extraction.error = crate::tf!(
"\"{name}\" is already loaded with different contents; the existing file was kept."
)
.into_owned();
}
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()
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_data_extraction_add_drawings(&mut self) -> Task<Message> {
Task::perform(
async {
Ok(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,
)
}
/// Web: the picked drawings have no paths to re-open later, so each one is
/// parsed on the shared worker right here and the document registered
/// under its name — the pipeline then reads the registry instead of the
/// filesystem. The first file that fails to parse aborts the add with the
/// same `name: error` shape the native refresh reports.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_data_extraction_add_drawings(&mut self) -> Task<Message> {
Task::perform(
async {
let handles = 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();
// Each picked file becomes a full parsed document held in
// the registry for the session; a bound keeps a giant
// multi-pick from exhausting the wasm heap (tab death).
const MAX_EXTRACTION_SOURCES: usize = 16;
if handles.len() > MAX_EXTRACTION_SOURCES {
return Err(crate::tf!(
"At most {MAX_EXTRACTION_SOURCES} drawings can be added in the browser."
)
.into_owned());
}
let mut paths = Vec::new();
for handle in handles {
let name = handle.file_name();
let bytes = handle.read().await;
let document = crate::io::parse_drawing_bytes_web(&name, &bytes)
.await
.map_err(|error| format!("{name}: {error}"))?;
register_extraction_document(&name, document);
paths.push(PathBuf::from(name));
}
Ok(paths)
},
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)
};
// Web output mirrors the native CSV/TXT write (there is no xlsx
// writer on either target): the browser gets the same bytes as a
// download under the picked name.
#[cfg(target_arch = "wasm32")]
{
let name = path
.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "extraction.csv".into());
crate::sys::download_bytes(&name, text.as_bytes());
}
#[cfg(not(target_arch = "wasm32"))]
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 {
/// A refused same-name re-pick must KEEP the old bytes: overwriting
/// would silently retarget every reference reading that name.
#[test]
fn collision_refusal_keeps_the_registered_bytes() {
register_picked_source("a.csv", Arc::new(b"old".to_vec()));
let fresh = register_picked_source("a.csv", Arc::new(b"new".to_vec()));
assert!(!fresh, "differing bytes under a live name are refused");
assert_eq!(
picked_source_bytes("a.csv").as_ref().map(|b| b.as_slice()),
Some(&b"old"[..]),
"the old registration survives the refusal"
);
// Identical bytes are a no-op accept.
assert!(register_picked_source("a.csv", Arc::new(b"old".to_vec())));
}
use super::*;
#[test]
fn cell_ranges_are_normalized_and_missing_cells_are_blank() {
let rows = vec![vec!["a".into(), "b".into(), "c".into()], vec!["d".into()]];
assert_eq!(
crop_range(rows, "C2:A1").unwrap(),
vec![
vec!["a".to_string(), "b".to_string(), "c".to_string()],
vec!["d".to_string(), String::new(), String::new()],
]
);
}
#[test]
fn named_range_targets_accept_quoted_sheet_names() {
assert_eq!(
named_range_target("='Sheet One'!$A$1:$D$10"),
Some(("Sheet One".into(), "A1:D10".into()))
);
}
#[test]
fn csv_links_apply_the_selected_cell_range() {
let path = std::env::temp_dir().join(format!(
"ocs-data-link-{}-{}.csv",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, "a,b,c\nd,e,f\n").unwrap();
let rows = read_tabular_file(&path, None, LinkRange::CellRange, Some("B1:C2"));
let _ = std::fs::remove_file(path);
assert_eq!(
rows.unwrap(),
vec![
vec!["b".to_string(), "c".to_string()],
vec!["e".to_string(), "f".to_string()],
]
);
}
/// `column_index` only checks `checked_mul(26)`, so a range like
/// `A1:ZZZZZZ1` parses fine — and `(c0..=c1)` then collects one `String`
/// per column: ~308 M slots (~7 GB) for 6 Z's, and a `capacity overflow`
/// panic at 13 Z's. Columns past the widest row are empty in *every*
/// row, so the window must be clamped to the data width.
#[test]
fn crop_range_bounds_oversized_column_spans() {
let rows = vec![vec!["x".to_string()]];
// 13 Z's: pre-fix, size_hint × size_of::<String>() exceeds isize::MAX
// and Vec's capacity check panics before a single cell is read.
assert_eq!(
crop_range(rows.clone(), "A1:ZZZZZZZZZZZZZ1").unwrap(),
vec![vec!["x".to_string()]]
);
// 6 Z's — the bug-hunt report's repro — stays bounded too.
assert_eq!(
crop_range(rows, "A1:ZZZZZZ1").unwrap(),
vec![vec!["x".to_string()]]
);
}
#[test]
fn csv_links_read_from_registered_bytes() {
let name = format!("ocs-registry-{}-{}.csv", std::process::id(), line!());
register_picked_source(&name, Arc::new(b"a,b,c\nd,e,f\n".to_vec()));
let rows = read_tabular_file(Path::new(&name), None, LinkRange::CellRange, Some("B1:C2"));
assert_eq!(
rows.unwrap(),
vec![
vec!["b".to_string(), "c".to_string()],
vec!["e".to_string(), "f".to_string()],
]
);
}
#[test]
fn registered_bytes_match_by_file_name() {
let key = format!("ocs-match-{}-{}.xlsx", std::process::id(), line!());
register_picked_source(&format!(r"C:\drawings\{key}"), Arc::new(Vec::new()));
// Exact key…
assert!(picked_source_bytes(&format!(r"C:\drawings\{key}")).is_some());
// …same file under a differently-cased bare name (the web stores only
// the name, links may store the author's path)…
assert!(picked_source_bytes(&key.to_ascii_uppercase()).is_some());
// …and not another file.
assert!(picked_source_bytes("ocs-match-other.xlsx").is_none());
}
#[test]
fn registered_extraction_documents_match_by_file_name() {
let key = format!("ocs-doc-{}-{}.dwg", std::process::id(), line!());
register_extraction_document(&key, CadDocument::new());
assert!(extraction_document(&key.to_ascii_uppercase()).is_some());
assert!(extraction_document("ocs-doc-other.dwg").is_none());
}
#[test]
fn workbook_metadata_bytes_rejects_non_workbooks() {
assert!(workbook_metadata_bytes(b"not a workbook").is_err());
}
}