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
@@ -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};
|
||||
|
||||
Reference in new issue
Block a user