forked from 1b-agent/opencadstudio-upstream
web: XREFs, data links & extraction, ARCHIVE transmittals, worker-side saves
The last four big web-vs-desktop gaps.
XREFs: XATTACH reads the picked drawing's bytes and registers them
name-keyed (the image/PDF-underlay pattern), pre-parses on the web
worker, and the commit merge produces the same xref block flow as
native. Opened drawings resolve xrefs from the registry (trailing-name
match) before cache build; unresolved references produce ONE warning
listing the names and the fix — drop the referenced files on the page
(drops and picker opens now register their bytes) and reopen or
XRELOAD. XRELOAD works off the registry on unsaved drawings too.
Data Link & Extraction: spreadsheet links read picked xlsx/csv bytes
(calamine from slices / CSV from string) through a session registry,
so preview, refresh, and table-insert work on web; extraction takes
external drawings via a multi-file picker parsed on the worker, and
outputs CSV/TXT/.dxex via download_bytes. The disabled 'Add folder'
button explains why.
ARCHIVE/ETRANSMIT: assembles the drawing plus registered images, PDF
underlays, and session plot tables into a ZIP delivered as
{stem}_transmit.zip; skipped dependencies are reported by name.
Save/autosave off the UI thread: the parse worker gains a serialize
action (protocol v5) — the document is prepared once on the main
thread (clone, styles, dimension bake), then the DWG/DXF writer pass,
the dominant cost, runs on the worker; any worker failure falls back
to inline serialization with the identical shared implementation.
The save path also clears the dirty flag only when the tab didn't
change mid-write, and the runtime-fields sidecar now carries
graphic_data, fixing a pre-existing loss of raw DWG round-trip bytes
on web-opened documents.
(cherry picked from commit 2e100634)
Ported onto main (#1717 review): the new ocs_save crate builds on the
pinned codec; the hand-written ZIP writer is replaced by the zip crate
already in the tree; ARCHIVE packs registered xref bytes too and
serializes document_for_save() like a web Save; the web save prepares
document_for_save() (prepare_document_for_save now takes the copy by
value, so no second clone); the wasm resolve_path consults the xref
registry, so every reference lookup — not just resolve — finds dropped
files; XRELOAD keeps main's undo, unload and stale-baseline handling;
native data extraction streams the source drawings one at a time.
This commit is contained in:
1 parent
9cd898a17a
commit
61c32c7f74
43 files changed
+2187
-336
No files matched your search
+361
-69
@@ -1,5 +1,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
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};
|
||||
@@ -25,6 +27,100 @@ struct ExtractionRecord {
|
||||
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))]
|
||||
pub(crate) fn register_picked_source(path: &str, bytes: Arc<Vec<u8>>) {
|
||||
picked_sources()
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.insert(path.to_string(), bytes);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -88,11 +184,15 @@ fn data_link_choices(doc: &CadDocument) -> Vec<DataLinkChoice> {
|
||||
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.to_string_lossy().into_owned(),
|
||||
valid: path.is_file(),
|
||||
path: path_text,
|
||||
valid,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
@@ -180,6 +280,12 @@ fn workbook_metadata(path: &Path) -> Result<(Vec<String>, Vec<String>), String>
|
||||
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();
|
||||
@@ -191,6 +297,20 @@ fn workbook_metadata(path: &Path) -> Result<(Vec<String>, Vec<String>), String>
|
||||
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('!')?;
|
||||
@@ -199,6 +319,66 @@ fn named_range_target(formula: &str) -> Option<(String, String)> {
|
||||
(!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>,
|
||||
@@ -214,53 +394,24 @@ pub(crate) fn read_tabular_file(
|
||||
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),
|
||||
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 {
|
||||
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)
|
||||
} 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())?;
|
||||
(
|
||||
data.rows()
|
||||
.map(|row| row.iter().map(ToString::to_string).collect())
|
||||
.collect(),
|
||||
(!cell_range.is_empty()).then_some(cell_range),
|
||||
)
|
||||
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()
|
||||
@@ -471,12 +622,14 @@ fn collect_records(
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
@@ -505,6 +658,7 @@ fn collect_drawing_paths(
|
||||
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 {
|
||||
@@ -515,6 +669,31 @@ fn extraction_drawing_paths(state: &DataExtractionState) -> Result<Vec<PathBuf>,
|
||||
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>>,
|
||||
@@ -560,11 +739,9 @@ fn extraction_preview(
|
||||
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);
|
||||
}
|
||||
for_each_extraction_document(state, |document| {
|
||||
collect_records(document, None, &allowed_types, &mut records);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
let props = state
|
||||
@@ -693,11 +870,25 @@ fn save_extraction_settings(state: &DataExtractionState) -> Result<(), String> {
|
||||
"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())
|
||||
#[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 = std::fs::read_to_string(&state.settings_path).map_err(|error| error.to_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() {
|
||||
@@ -1075,7 +1266,7 @@ impl OpenCADStudio {
|
||||
pub(super) fn on_data_link_browse(&mut self) -> Task<Message> {
|
||||
Task::perform(
|
||||
async {
|
||||
crate::sys::file_dialog()
|
||||
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(),
|
||||
@@ -1084,7 +1275,16 @@ impl OpenCADStudio {
|
||||
.add_filter(crate::t!("All Files").as_ref(), &["*"])
|
||||
.pick_file()
|
||||
.await
|
||||
.map(|handle| crate::sys::handle_path(&handle))
|
||||
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")]
|
||||
register_picked_source(&path.to_string_lossy(), Arc::new(handle.read().await));
|
||||
Some(path)
|
||||
},
|
||||
Message::DataLinkBrowseResult,
|
||||
)
|
||||
@@ -1403,11 +1603,9 @@ impl OpenCADStudio {
|
||||
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);
|
||||
}
|
||||
for_each_extraction_document(&state, |document| {
|
||||
collect_type_counts(document, None, &mut counts);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
let previous = self
|
||||
@@ -1570,18 +1768,21 @@ impl OpenCADStudio {
|
||||
.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))
|
||||
let handle = if save {
|
||||
dialog.set_file_name("extraction.dxex").save_file().await
|
||||
} else {
|
||||
dialog
|
||||
.pick_file()
|
||||
.await
|
||||
.map(|handle| crate::sys::handle_path(&handle))
|
||||
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 {
|
||||
register_picked_source(&path.to_string_lossy(), Arc::new(handle.read().await));
|
||||
}
|
||||
Some(path)
|
||||
},
|
||||
Message::DataExtractionBrowseSettingsResult,
|
||||
)
|
||||
@@ -1604,10 +1805,11 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn on_data_extraction_add_drawings(&mut self) -> Task<Message> {
|
||||
Task::perform(
|
||||
async {
|
||||
crate::sys::file_dialog()
|
||||
Ok(crate::sys::file_dialog()
|
||||
.set_title(crate::t!("Add Drawings").as_ref())
|
||||
.add_filter(
|
||||
crate::t!("CAD Files").as_ref(),
|
||||
@@ -1619,7 +1821,42 @@ impl OpenCADStudio {
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|handle| crate::sys::handle_path(&handle))
|
||||
.collect()
|
||||
.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();
|
||||
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,
|
||||
)
|
||||
@@ -1740,6 +1977,18 @@ impl OpenCADStudio {
|
||||
} 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();
|
||||
@@ -1835,4 +2084,47 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1612,9 +1612,20 @@ impl OpenCADStudio {
|
||||
"XRELOAD" => {
|
||||
// Reload all xrefs for the current drawing. Every direct key
|
||||
// leaves the session-unloaded set (reloaded content is live
|
||||
// again) with fresh stat baselines on the next refresh.
|
||||
if let Some(path) = &self.tabs[i].current_path.clone() {
|
||||
if let Some(base_dir) = path.parent() {
|
||||
// again) with fresh stat baselines on the next refresh. Web:
|
||||
// xrefs re-read from the in-session bytes registry, so an
|
||||
// unsaved drawing reloads too; native needs the drawing's own
|
||||
// folder for relative xref paths.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let base_dir: Option<std::path::PathBuf> = Some(std::path::PathBuf::new());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let base_dir: Option<std::path::PathBuf> = self.tabs[i]
|
||||
.current_path
|
||||
.as_ref()
|
||||
.and_then(|path| path.parent().map(std::path::Path::to_path_buf));
|
||||
if let Some(base_dir) = base_dir {
|
||||
{
|
||||
let base_dir: &std::path::Path = &base_dir;
|
||||
let reload_keys: Vec<u64> = crate::io::xref::collect_entries_with_prev(
|
||||
&self.tabs[i].scene.document,
|
||||
base_dir,
|
||||
@@ -1631,7 +1642,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
let (infos, _dropped) = crate::io::xref::resolve_xrefs(
|
||||
&mut self.tabs[i].scene.document,
|
||||
base_dir,
|
||||
&base_dir,
|
||||
);
|
||||
// `resolve_xrefs` re-merges ALL references — refresh stat
|
||||
// baselines for every currently-Loaded entry so no row
|
||||
@@ -1652,8 +1663,17 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
for info in &infos {
|
||||
// Web: unresolved references surface once below,
|
||||
// with the drop hint, instead of a filesystem path
|
||||
// no browser can open.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if info.status == crate::io::xref::XrefStatus::NotFound {
|
||||
continue;
|
||||
}
|
||||
self.report_xref_status(info);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.warn_unresolved_xrefs(&infos);
|
||||
self.post_ref_op(i);
|
||||
}
|
||||
} else {
|
||||
|
||||
+275
-56
@@ -169,72 +169,81 @@ impl OpenCADStudio {
|
||||
|
||||
// ARCHIVE / ETRANSMIT — package the drawing and its referenced files
|
||||
// (xrefs + raster images) into a sibling "<name>_archive" folder.
|
||||
// Web: there is no folder to copy into — the same dependency set is
|
||||
// collected from the session's bytes registries and delivered as a
|
||||
// "<name>_transmit.zip" download (`archive_web` below).
|
||||
"ARCHIVE" | "ETRANSMIT" => {
|
||||
use std::path::PathBuf;
|
||||
if let Some(src) = self.tabs[i].current_path.clone() {
|
||||
let stem = src
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "drawing".into());
|
||||
let parent = src
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let folder = parent.join(format!("{stem}_archive"));
|
||||
match std::fs::create_dir_all(&folder) {
|
||||
Ok(()) => {
|
||||
let mut copied = 0usize;
|
||||
if let Some(fname) = src.file_name() {
|
||||
if std::fs::copy(&src, folder.join(fname)).is_ok() {
|
||||
copied += 1;
|
||||
}
|
||||
}
|
||||
let mut deps: Vec<String> = Vec::new();
|
||||
for br in self.tabs[i].scene.document.block_records.iter() {
|
||||
if !br.xref_path.trim().is_empty() {
|
||||
deps.push(br.xref_path.clone());
|
||||
}
|
||||
}
|
||||
for e in self.tabs[i].scene.document.entities() {
|
||||
if let codec::EntityType::RasterImage(img) = e {
|
||||
if !img.file_path.trim().is_empty() {
|
||||
deps.push(img.file_path.clone());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::path::PathBuf;
|
||||
if let Some(src) = self.tabs[i].current_path.clone() {
|
||||
let stem = src
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "drawing".into());
|
||||
let parent = src
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let folder = parent.join(format!("{stem}_archive"));
|
||||
match std::fs::create_dir_all(&folder) {
|
||||
Ok(()) => {
|
||||
let mut copied = 0usize;
|
||||
if let Some(fname) = src.file_name() {
|
||||
if std::fs::copy(&src, folder.join(fname)).is_ok() {
|
||||
copied += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for d in deps {
|
||||
let dp = PathBuf::from(&d);
|
||||
let resolved = if dp.is_absolute() {
|
||||
dp
|
||||
} else {
|
||||
parent.join(&dp)
|
||||
};
|
||||
if resolved.exists() {
|
||||
if let Some(fname) = resolved.file_name() {
|
||||
if std::fs::copy(&resolved, folder.join(fname)).is_ok() {
|
||||
copied += 1;
|
||||
let mut deps: Vec<String> = Vec::new();
|
||||
for br in self.tabs[i].scene.document.block_records.iter() {
|
||||
if !br.xref_path.trim().is_empty() {
|
||||
deps.push(br.xref_path.clone());
|
||||
}
|
||||
}
|
||||
for e in self.tabs[i].scene.document.entities() {
|
||||
if let codec::EntityType::RasterImage(img) = e {
|
||||
if !img.file_path.trim().is_empty() {
|
||||
deps.push(img.file_path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for d in deps {
|
||||
let dp = PathBuf::from(&d);
|
||||
let resolved = if dp.is_absolute() {
|
||||
dp
|
||||
} else {
|
||||
parent.join(&dp)
|
||||
};
|
||||
if resolved.exists() {
|
||||
if let Some(fname) = resolved.file_name() {
|
||||
if std::fs::copy(&resolved, folder.join(fname)).is_ok()
|
||||
{
|
||||
copied += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.command_line.push_output(
|
||||
crate::tf!(
|
||||
"{cmd}: packaged {copied} file(s) into {}",
|
||||
folder.display()
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
self.command_line.push_output(
|
||||
crate::tf!(
|
||||
"{cmd}: packaged {copied} file(s) into {}",
|
||||
folder.display()
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
Err(e) => self.command_line.push_error(
|
||||
crate::tf!("{cmd}: cannot create folder ({e}).").as_ref(),
|
||||
),
|
||||
}
|
||||
Err(e) => self
|
||||
.command_line
|
||||
.push_error(crate::tf!("{cmd}: cannot create folder ({e}).").as_ref()),
|
||||
} else {
|
||||
self.command_line.push_error(
|
||||
crate::t!("ARCHIVE: save the drawing first (it has no file path yet).")
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.command_line.push_error(
|
||||
crate::t!("ARCHIVE: save the drawing first (it has no file path yet).")
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.archive_web(i, cmd);
|
||||
}
|
||||
|
||||
"EXIT" | "QUIT" => {
|
||||
@@ -545,6 +554,216 @@ fn parse_background_color(args: &[&str]) -> Option<[f32; 4]> {
|
||||
None
|
||||
}
|
||||
|
||||
// ── Web ARCHIVE / ETRANSMIT ──────────────────────────────────────────────────
|
||||
//
|
||||
// The browser has no folder to copy into, so the transmittal is assembled in
|
||||
// memory and downloaded as one ZIP — the same dependency set the native
|
||||
// folder walk collects (drawing, xrefs, raster images) plus the web-only
|
||||
// bytes-backed kinds (PDF underlays, session-picked plot tables). Anything
|
||||
// whose bytes never entered the session is reported as skipped rather than
|
||||
// silently dropped, the web counterpart of native's missing-file skip.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl OpenCADStudio {
|
||||
pub(in crate::app) fn archive_web(&mut self, i: usize, cmd: &str) {
|
||||
let Some(src) = self.tabs[i].current_path.clone() else {
|
||||
self.command_line.push_error(
|
||||
crate::t!("ARCHIVE: save the drawing first (it has no file path yet).").as_ref(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let stem = src
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "drawing".into());
|
||||
let ext = src
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "dwg".into());
|
||||
let filename = src
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| format!("{stem}.{ext}"));
|
||||
let version = self.tabs[i].scene.document.version;
|
||||
|
||||
// The drawing itself: the same serialization a web Save performs
|
||||
// (native copies the last-saved file; the web build has none, so the
|
||||
// current document is re-serialized).
|
||||
let document = self.tabs[i].scene.document_for_save();
|
||||
let drawing = match crate::io::save_to_bytes(&document, &ext, version) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
self.command_line.push_error(
|
||||
crate::tf!("{cmd}: cannot package the drawing ({e}).").as_ref(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut entries: Vec<(String, Vec<u8>)> = vec![(filename, drawing)];
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
|
||||
// Xrefs — the block-record walk native copies; the bytes come from
|
||||
// the session's xref registry (picked, dropped or opened files).
|
||||
for br in self.tabs[i].scene.document.block_records.iter() {
|
||||
if br.xref_path.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
match crate::io::xref::find_registered_source(&br.xref_path)
|
||||
.and_then(|key| crate::io::xref::registered_bytes(&key))
|
||||
{
|
||||
Some(bytes) => push_entry(
|
||||
&mut entries,
|
||||
transmit_file_name(&br.xref_path),
|
||||
(*bytes).clone(),
|
||||
),
|
||||
None => push_skipped(&mut skipped, &transmit_file_name(&br.xref_path)),
|
||||
}
|
||||
}
|
||||
|
||||
// Raster images — picked/dropped bytes registered for the reference
|
||||
// stand in for the file native would copy.
|
||||
for e in self.tabs[i].scene.document.entities() {
|
||||
if let codec::EntityType::RasterImage(img) = e {
|
||||
if img.file_path.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
match crate::scene::model::image_model::registered_source_bytes(&img.file_path) {
|
||||
Some(bytes) => push_entry(
|
||||
&mut entries,
|
||||
transmit_file_name(&img.file_path),
|
||||
(*bytes).clone(),
|
||||
),
|
||||
None => push_skipped(&mut skipped, &transmit_file_name(&img.file_path)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PDF underlays — bytes registered at attach time.
|
||||
for o in self.tabs[i].scene.document.objects.values() {
|
||||
if let codec::objects::ObjectType::UnderlayDefinition(def) = o {
|
||||
if !matches!(def.underlay_type, codec::entities::UnderlayType::Pdf)
|
||||
|| def.file_path.trim().is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match crate::scene::model::pdf_raster::registered_source_bytes(&def.file_path) {
|
||||
Some(bytes) => push_entry(
|
||||
&mut entries,
|
||||
transmit_file_name(&def.file_path),
|
||||
(*bytes).clone(),
|
||||
),
|
||||
None => push_skipped(&mut skipped, &transmit_file_name(&def.file_path)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plot tables named by the layouts (and the session's active one).
|
||||
// Only tables picked this session have bytes; the built-ins ship
|
||||
// with the app, so they are neither packaged nor reported missing.
|
||||
let mut style_names: Vec<String> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.objects
|
||||
.values()
|
||||
.filter_map(|o| {
|
||||
if let codec::objects::ObjectType::Layout(l) = o {
|
||||
Some(l.plot_style_sheet.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if let Some(table) = self.active_plot_style.as_ref() {
|
||||
style_names.push(table.name.clone());
|
||||
}
|
||||
for name in style_names {
|
||||
if name.trim().is_empty()
|
||||
|| crate::io::plot_style::PlotStyleTable::builtin(&name).is_ok()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match crate::io::plot_style::session_table(&name) {
|
||||
Some(bytes) => push_entry(&mut entries, name, (*bytes).clone()),
|
||||
None => push_skipped(&mut skipped, &name),
|
||||
}
|
||||
}
|
||||
|
||||
let zip_name = format!("{stem}_transmit.zip");
|
||||
match build_zip(&entries) {
|
||||
Ok(zip) => {
|
||||
crate::sys::download_bytes(&zip_name, &zip);
|
||||
// The same report shape the native folder transmittal makes.
|
||||
let copied = entries.len();
|
||||
self.command_line.push_output(
|
||||
crate::tf!("{cmd}: packaged {copied} file(s) into {}", zip_name).as_ref(),
|
||||
);
|
||||
}
|
||||
Err(e) => self.command_line.push_error(
|
||||
crate::tf!("{cmd}: cannot build the transmittal ZIP ({e}).").as_ref(),
|
||||
),
|
||||
}
|
||||
if !skipped.is_empty() {
|
||||
let n = skipped.len();
|
||||
let names = skipped.join(", ");
|
||||
self.command_line.push_output(
|
||||
crate::tf!("{cmd}: skipped {n} referenced file(s) not loaded in this session: {names}")
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trailing component of a reference path — the flat name a transmittal
|
||||
/// packages it under (native copies `file_name()` into one folder).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn transmit_file_name(path: &str) -> String {
|
||||
path.trim()
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Add one entry under its flat name; a later dependency with the same name
|
||||
/// replaces the bytes (native's `fs::copy` overwrite into the folder).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn push_entry(entries: &mut Vec<(String, Vec<u8>)>, name: String, data: Vec<u8>) {
|
||||
if let Some(slot) = entries
|
||||
.iter_mut()
|
||||
.find(|(entry, _)| entry.eq_ignore_ascii_case(&name))
|
||||
{
|
||||
slot.1 = data;
|
||||
} else {
|
||||
entries.push((name, data));
|
||||
}
|
||||
}
|
||||
|
||||
/// Pack the transmittal entries into one deflated ZIP archive in memory.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn build_zip(entries: &[(String, Vec<u8>)]) -> Result<Vec<u8>, String> {
|
||||
use std::io::Write;
|
||||
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
for (name, data) in entries {
|
||||
writer
|
||||
.start_file(name.as_str(), options)
|
||||
.map_err(|e| e.to_string())?;
|
||||
writer.write_all(data).map_err(|e| e.to_string())?;
|
||||
}
|
||||
writer
|
||||
.finish()
|
||||
.map(std::io::Cursor::into_inner)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Remember a missing dependency for the report, once per name.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn push_skipped(skipped: &mut Vec<String>, name: &str) {
|
||||
if !name.is_empty() && !skipped.iter().any(|s| s.eq_ignore_ascii_case(name)) {
|
||||
skipped.push(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{Message, OpenCADStudio};
|
||||
|
||||
+19
-1
@@ -2650,6 +2650,22 @@ pub enum Message {
|
||||
bounds: Option<iced::Rectangle>,
|
||||
screenshot: Option<iced::window::Screenshot>,
|
||||
},
|
||||
/// Web SAVE: the serializer worker (or the inline fallback) produced the
|
||||
/// drawing bytes; download them and finish the save bookkeeping. The
|
||||
/// epoch/revision/camera markers snapshot the tab when serialization
|
||||
/// started — the web counterpart of native's `snapshot_is_current`, so a
|
||||
/// save that raced user edits does not wipe the newer dirty flag.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
WebSaveSerialized {
|
||||
tab_id: u64,
|
||||
filename: String,
|
||||
autosave_key: String,
|
||||
version: codec::DxfVersion,
|
||||
geometry_epoch: u64,
|
||||
edit_revision: u64,
|
||||
camera_generation: u64,
|
||||
result: Result<Vec<u8>, String>,
|
||||
},
|
||||
/// Retry the failed save after the other application releases the file.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
SaveFileInUseRetry,
|
||||
@@ -4127,7 +4143,9 @@ pub enum Message {
|
||||
DataExtractionBrowseSettings,
|
||||
DataExtractionBrowseSettingsResult(Option<std::path::PathBuf>),
|
||||
DataExtractionAddDrawings,
|
||||
DataExtractionAddDrawingsResult(Vec<std::path::PathBuf>),
|
||||
/// Picked source drawings — `Err` carries a pick/parse failure (web
|
||||
/// parses each file on the worker at pick time).
|
||||
DataExtractionAddDrawingsResult(Result<Vec<std::path::PathBuf>, String>),
|
||||
DataExtractionAddFolder,
|
||||
DataExtractionAddFolderResult(Option<std::path::PathBuf>),
|
||||
DataExtractionClearSources,
|
||||
|
||||
+164
-64
@@ -1958,11 +1958,18 @@ impl OpenCADStudio {
|
||||
crate::tf!("XREF Recovered with warnings: \"{}\"", info.name).as_ref(),
|
||||
);
|
||||
}
|
||||
// Web: NotFound xrefs are summarized in one warning after the
|
||||
// loop (see below) — the per-reference line carries a
|
||||
// filesystem path no browser can open, and a drawing with
|
||||
// several unresolved xrefs would repeat the same hint.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::io::xref::XrefStatus::NotFound => {
|
||||
self.command_line.push_error(
|
||||
crate::tf!("XREF Not found: \"{}\" ({})", info.name, info.path).as_ref(),
|
||||
);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
crate::io::xref::XrefStatus::NotFound => {}
|
||||
crate::io::xref::XrefStatus::Failed => {
|
||||
self.command_line.push_error(
|
||||
crate::tf!("XREF Recovery failed: \"{}\" ({})", info.name, info.path)
|
||||
@@ -1976,6 +1983,11 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Web: one warning per open lists every unresolved xref and the way
|
||||
// to fix it — dropping the files on the page registers their bytes,
|
||||
// and reopening the drawing (or XRELOAD) then resolves them.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.warn_unresolved_xrefs(&caches.xrefs);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let thumbs_task = self.push_recent(path.clone());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -2589,83 +2601,144 @@ impl OpenCADStudio {
|
||||
self.tabs[i].scene.document.preview = Some(preview);
|
||||
}
|
||||
|
||||
let mut recent_task = Task::none();
|
||||
// The name the autosave snapshot for this tab is keyed by — capture it
|
||||
// before the save re-points `current_path` at the download name.
|
||||
let autosave_key = Self::web_autosave_key(&self.tabs[i]);
|
||||
let document = self.tabs[i].scene.document_for_save();
|
||||
let saved = match crate::io::save_to_bytes(&document, &ext, version) {
|
||||
Ok(bytes) => {
|
||||
crate::sys::download_bytes(&filename, &bytes);
|
||||
let cache_name = std::path::Path::new(&filename)
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| filename.clone());
|
||||
let path = std::path::PathBuf::from(cache_name);
|
||||
self.tabs[i].current_path = Some(path.clone());
|
||||
self.tabs[i].scene.document.version = version;
|
||||
self.tabs[i].dirty = false;
|
||||
self.tabs[i].recovery_save_as_required = false;
|
||||
recent_task = Task::perform(
|
||||
async move {
|
||||
crate::io::web_recent::store(&path.to_string_lossy(), &bytes)
|
||||
.await
|
||||
.map(|_| path)
|
||||
},
|
||||
Message::WebRecentStored,
|
||||
);
|
||||
// A clean save supersedes any crash-recovery snapshot for this
|
||||
// drawing — drop the old key and the new one so stale copies
|
||||
// don't resurface in the next boot scan. But an autosave
|
||||
// round may still be writing the same key: its write would
|
||||
// land AFTER the remove and resurrect the snapshot (with a
|
||||
// newer mtime, so the next scan would offer it as
|
||||
// recoverable). Defer the removes to the end of the round.
|
||||
let new_key = self.tabs[i]
|
||||
.current_path
|
||||
.as_ref()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
if self.web_autosave_in_flight {
|
||||
self.web_autosave_clear_after.push(autosave_key);
|
||||
if !new_key.is_empty() {
|
||||
self.web_autosave_clear_after.push(new_key);
|
||||
}
|
||||
} else {
|
||||
recent_task = Task::batch([
|
||||
recent_task,
|
||||
Task::perform(
|
||||
async move {
|
||||
let _ = crate::io::web_autosave::remove(&autosave_key).await;
|
||||
let _ = crate::io::web_autosave::remove(&new_key).await;
|
||||
},
|
||||
|_| Message::Noop,
|
||||
),
|
||||
]);
|
||||
}
|
||||
self.command_line
|
||||
.push_output(crate::tf!("Saved: {filename}").as_ref());
|
||||
true
|
||||
}
|
||||
// Race guard (the web counterpart of native's `snapshot_is_current`):
|
||||
// completion only clears the dirty flag if the tab did not change
|
||||
// while the writer ran off-thread.
|
||||
let geometry_epoch = self.tabs[i].scene.geometry_epoch;
|
||||
let edit_revision = self.tabs[i].edit_revision;
|
||||
let camera_generation = self.tabs[i].scene.camera_generation;
|
||||
// Prepare the document here (clone + style sync + dimension bake — the
|
||||
// app-side half of the pipeline), then hand the DWG/DXF writer pass to
|
||||
// the browser worker so the UI thread stays responsive on large
|
||||
// drawings. Completion (download + bookkeeping) continues in
|
||||
// `on_web_save_serialized`.
|
||||
let prepared =
|
||||
crate::io::prepare_document_for_save(self.tabs[i].scene.document_for_save(), version);
|
||||
Task::perform(
|
||||
async move { crate::io::serialize_prepared_document(&prepared, &ext).await },
|
||||
move |result| Message::WebSaveSerialized {
|
||||
tab_id,
|
||||
filename,
|
||||
autosave_key,
|
||||
version,
|
||||
geometry_epoch,
|
||||
edit_revision,
|
||||
camera_generation,
|
||||
result,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Web SAVE completion: the serializer worker (or inline fallback) has the
|
||||
/// drawing bytes. The download and tab bookkeeping that used to run
|
||||
/// synchronously inside `on_web_save_screenshot`, unchanged in semantics —
|
||||
/// except the dirty flag is only cleared when the serialized snapshot is
|
||||
/// still the tab's current state.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn on_web_save_serialized(
|
||||
&mut self,
|
||||
tab_id: u64,
|
||||
filename: String,
|
||||
autosave_key: String,
|
||||
version: codec::DxfVersion,
|
||||
geometry_epoch: u64,
|
||||
edit_revision: u64,
|
||||
camera_generation: u64,
|
||||
result: Result<Vec<u8>, String>,
|
||||
) -> Task<Message> {
|
||||
let bytes = match result {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
self.command_line
|
||||
.push_error(crate::tf!("Save failed: {error}").as_ref());
|
||||
false
|
||||
return self.finish_web_save(false);
|
||||
}
|
||||
};
|
||||
// The download is the only artifact of the save — fire it even if the
|
||||
// tab was closed while serialization was in flight.
|
||||
crate::sys::download_bytes(&filename, &bytes);
|
||||
let cache_name = std::path::Path::new(&filename)
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| filename.clone());
|
||||
let path = std::path::PathBuf::from(cache_name);
|
||||
let mut recent_task = Task::perform(
|
||||
async move {
|
||||
crate::io::web_recent::store(&path.to_string_lossy(), &bytes)
|
||||
.await
|
||||
.map(|_| path)
|
||||
},
|
||||
Message::WebRecentStored,
|
||||
);
|
||||
let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
|
||||
return recent_task;
|
||||
};
|
||||
self.tabs[i].current_path = Some(
|
||||
std::path::Path::new(&filename)
|
||||
.file_name()
|
||||
.map(|name| std::path::PathBuf::from(name.to_string_lossy().into_owned()))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(filename.clone())),
|
||||
);
|
||||
self.tabs[i].scene.document.version = version;
|
||||
self.tabs[i].recovery_save_as_required = false;
|
||||
let snapshot_is_current = self.tabs[i].scene.geometry_epoch == geometry_epoch
|
||||
&& self.tabs[i].edit_revision == edit_revision
|
||||
&& self.tabs[i].scene.camera_generation == camera_generation;
|
||||
if snapshot_is_current {
|
||||
self.tabs[i].dirty = false;
|
||||
}
|
||||
// A clean save supersedes any crash-recovery snapshot for this
|
||||
// drawing — drop the old key and the new one so stale copies don't
|
||||
// resurface in the next boot scan. But an autosave round may still be
|
||||
// writing the same key: its write would land AFTER the remove and
|
||||
// resurrect the snapshot (with a newer mtime, so the next scan would
|
||||
// offer it as recoverable). Defer the removes to the end of the round.
|
||||
let new_key = self.tabs[i]
|
||||
.current_path
|
||||
.as_ref()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
if self.web_autosave_in_flight {
|
||||
self.web_autosave_clear_after.push(autosave_key);
|
||||
if !new_key.is_empty() {
|
||||
self.web_autosave_clear_after.push(new_key);
|
||||
}
|
||||
} else {
|
||||
recent_task = Task::batch([
|
||||
recent_task,
|
||||
Task::perform(
|
||||
async move {
|
||||
let _ = crate::io::web_autosave::remove(&autosave_key).await;
|
||||
let _ = crate::io::web_autosave::remove(&new_key).await;
|
||||
},
|
||||
|_| Message::Noop,
|
||||
),
|
||||
]);
|
||||
}
|
||||
self.command_line
|
||||
.push_output(crate::tf!("Saved: {filename}").as_ref());
|
||||
Task::batch([recent_task, self.finish_web_save(true)])
|
||||
}
|
||||
|
||||
/// Tail of the web save flow: continue a pending close when the save
|
||||
/// succeeded, or re-open the unsaved-changes dialog when it failed.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn finish_web_save(&mut self, saved: bool) -> Task<Message> {
|
||||
if self.save_dialog_for_unsaved {
|
||||
if saved {
|
||||
if let Some(crate::app::PendingClose::Tab(tab_id)) = self.pending_close.take() {
|
||||
let continuation = self.update(Message::TabClose(tab_id));
|
||||
let rest = self.continue_tab_close_queue();
|
||||
return Task::batch([recent_task, continuation, rest]);
|
||||
return Task::batch([continuation, rest]);
|
||||
}
|
||||
} else if self.pending_close.is_some() {
|
||||
let retry = self.open_unsaved_dialog_window();
|
||||
return Task::batch([recent_task, retry]);
|
||||
return self.open_unsaved_dialog_window();
|
||||
}
|
||||
}
|
||||
recent_task
|
||||
Task::none()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -3661,7 +3734,10 @@ impl OpenCADStudio {
|
||||
/// native `.sv$` copy — best-effort and non-destructive: no download, no
|
||||
/// recents update, dirty flag untouched. Snapshots run serially in one
|
||||
/// round; while it lasts (or a web save's thumbnail capture is pending)
|
||||
/// further ticks are dropped, not queued.
|
||||
/// further ticks are dropped, not queued. Each tab is prepared (clone +
|
||||
/// style sync + dimension bake) once up front, then the DWG/DXF writer
|
||||
/// pass runs on the serializer worker — the UI thread only awaits the
|
||||
/// result between OPFS writes.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn on_autosave(&mut self) -> Task<Message> {
|
||||
if self.web_autosave_in_flight || self.pending_web_thumbnail_save.is_some() {
|
||||
@@ -3688,7 +3764,11 @@ impl OpenCADStudio {
|
||||
let is_dxf = crate::io::source_is_dxf(tab.current_path.as_deref(), &tab.scene.document);
|
||||
let ext = if is_dxf { "dxf" } else { "dwg" }.to_string();
|
||||
let version = tab.scene.document.version;
|
||||
pending.push((name, ext, version, tab.scene.document.clone()));
|
||||
pending.push((
|
||||
name,
|
||||
ext,
|
||||
crate::io::prepare_document_for_save(tab.scene.document.clone(), version),
|
||||
));
|
||||
}
|
||||
if pending.is_empty() {
|
||||
return Task::none();
|
||||
@@ -3697,10 +3777,10 @@ impl OpenCADStudio {
|
||||
Task::perform(
|
||||
async move {
|
||||
let mut failure = None;
|
||||
for (name, ext, version, doc) in pending {
|
||||
for (name, ext, prepared) in pending {
|
||||
// Autosave exists to recover geometry, not pretty recents:
|
||||
// no thumbnail capture, just bytes into OPFS.
|
||||
match crate::io::save_to_bytes(&doc, &ext, version) {
|
||||
match crate::io::serialize_prepared_document(&prepared, &ext).await {
|
||||
Ok(bytes) => {
|
||||
if let Err(error) =
|
||||
crate::io::web_autosave::snapshot(&name, &bytes, None).await
|
||||
@@ -8132,3 +8212,23 @@ mod background_task_tests {
|
||||
assert!(error.contains("worker exploded"), "error = {error}");
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// Web: one warning lists every unresolved xref and the way to fix it —
|
||||
/// dropping the files on the page registers their bytes, and reopening
|
||||
/// the drawing (or XRELOAD) then resolves them.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(in crate::app) fn warn_unresolved_xrefs(&mut self, infos: &[crate::io::xref::XrefInfo]) {
|
||||
let unresolved = crate::io::xref::unresolved_xref_names(infos);
|
||||
if unresolved.is_empty() {
|
||||
return;
|
||||
}
|
||||
let names = unresolved.join(", ");
|
||||
self.command_line.push_warning(
|
||||
crate::tf!(
|
||||
"XREF Unresolved external references: {names} — drop the referenced DWG/DXF files on the page to register them, then reopen this drawing or run XRELOAD."
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+80
-26
@@ -1580,26 +1580,46 @@ impl OpenCADStudio {
|
||||
|
||||
Task::none()
|
||||
}
|
||||
Message::XAttachPick => Task::perform(
|
||||
async {
|
||||
let handle = crate::sys::file_dialog()
|
||||
.set_title(crate::t!("Select External Reference File").as_ref())
|
||||
.add_filter(
|
||||
crate::t!("CAD Files").as_ref(),
|
||||
&["dwg", "dxf", "bak", "DWG", "DXF", "BAK"],
|
||||
)
|
||||
.add_filter(crate::t!("DWG Files").as_ref(), &["dwg", "DWG"])
|
||||
.add_filter(crate::t!("DXF Files").as_ref(), &["dxf", "DXF"])
|
||||
.add_filter(crate::t!("Backup Files").as_ref(), &["bak", "BAK"])
|
||||
.pick_file()
|
||||
.await;
|
||||
match handle {
|
||||
Some(h) => Ok(crate::sys::handle_path(&h)),
|
||||
None => Err("Cancelled".to_string()),
|
||||
}
|
||||
},
|
||||
Message::XAttachPickResult,
|
||||
),
|
||||
Message::XAttachPick => {
|
||||
// Web: the browser hands back bytes, not a path — read them,
|
||||
// register them name-keyed (so reloads and re-opens of the
|
||||
// host drawing re-resolve the xref in-session) and pre-parse
|
||||
// on the worker in one step. The returned name-only PathBuf
|
||||
// is what the native branch's `handle_path` yields there.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
Task::perform(
|
||||
crate::io::pick_and_register_xref_web(),
|
||||
Message::XAttachPickResult,
|
||||
)
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
Task::perform(
|
||||
async {
|
||||
let handle = crate::sys::file_dialog()
|
||||
.set_title(crate::t!("Select External Reference File").as_ref())
|
||||
.add_filter(
|
||||
crate::t!("CAD Files").as_ref(),
|
||||
&["dwg", "dxf", "bak", "DWG", "DXF", "BAK"],
|
||||
)
|
||||
.add_filter(crate::t!("DWG Files").as_ref(), &["dwg", "DWG"])
|
||||
.add_filter(crate::t!("DXF Files").as_ref(), &["dxf", "DXF"])
|
||||
.add_filter(
|
||||
crate::t!("Backup Files").as_ref(),
|
||||
&["bak", "BAK"],
|
||||
)
|
||||
.pick_file()
|
||||
.await;
|
||||
match handle {
|
||||
Some(h) => Ok(crate::sys::handle_path(&h)),
|
||||
None => Err("Cancelled".to_string()),
|
||||
}
|
||||
},
|
||||
Message::XAttachPickResult,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Message::XAttachPickResult(Ok(path)) => {
|
||||
self.open_xref_attach_dialog(path);
|
||||
@@ -1692,9 +1712,13 @@ impl OpenCADStudio {
|
||||
self.on_data_extraction_browse_settings_result(path)
|
||||
}
|
||||
Message::DataExtractionAddDrawings => self.on_data_extraction_add_drawings(),
|
||||
Message::DataExtractionAddDrawingsResult(paths) => {
|
||||
Message::DataExtractionAddDrawingsResult(Ok(paths)) => {
|
||||
self.on_data_extraction_add_drawings_result(paths)
|
||||
}
|
||||
Message::DataExtractionAddDrawingsResult(Err(error)) => {
|
||||
self.data_extraction.error = error;
|
||||
Task::none()
|
||||
}
|
||||
Message::DataExtractionAddFolder => self.on_data_extraction_add_folder(),
|
||||
Message::DataExtractionAddFolderResult(path) => {
|
||||
self.on_data_extraction_add_folder_result(path)
|
||||
@@ -1728,13 +1752,22 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
Message::DataExtractionSaveResult(csv, Some(path)) => {
|
||||
match std::fs::write(&path, csv.as_bytes()) {
|
||||
let fname = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||||
// The browser has no writable path behind the picked name:
|
||||
// the CSV leaves as a download instead of an fs::write.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let write_result: Result<(), String> = {
|
||||
crate::sys::download_bytes(&fname, csv.as_bytes());
|
||||
Ok(())
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let write_result = std::fs::write(&path, csv.as_bytes()).map_err(|e| e.to_string());
|
||||
match write_result {
|
||||
Ok(()) => {
|
||||
let rows = csv.lines().count().saturating_sub(1);
|
||||
let fname = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||||
self.command_line.push_output(
|
||||
crate::tf!("DATAEXTRACTION {rows} rows → \"{fname}\"").as_ref(),
|
||||
);
|
||||
@@ -10103,6 +10136,27 @@ impl OpenCADStudio {
|
||||
screenshot,
|
||||
} => self.on_web_save_screenshot(tab_id, filename, ext, version, bounds, screenshot),
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::WebSaveSerialized {
|
||||
tab_id,
|
||||
filename,
|
||||
autosave_key,
|
||||
version,
|
||||
geometry_epoch,
|
||||
edit_revision,
|
||||
camera_generation,
|
||||
result,
|
||||
} => self.on_web_save_serialized(
|
||||
tab_id,
|
||||
filename,
|
||||
autosave_key,
|
||||
version,
|
||||
geometry_epoch,
|
||||
edit_revision,
|
||||
camera_generation,
|
||||
result,
|
||||
),
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Message::SaveFileInUseRetry => self.on_save_file_in_use_retry(),
|
||||
|
||||
|
||||
Reference in new issue
Block a user