diff --git a/Cargo.lock b/Cargo.lock index 434e3165..74aec9cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2 0.10.9", "truck-meshalgo", "truck-modeling", "truck-polymesh", @@ -84,7 +85,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.0" -source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=78b57b0#78b57b000d708864d304ec52cf5cd1eba1bb2626" +source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=6f9a92f#6f9a92f665b88c29970b7efd658ecf30dc589750" dependencies = [ "ahash 0.8.12", "anyhow", @@ -3983,6 +3984,7 @@ dependencies = [ "console_error_panic_hook", "getrandom 0.3.4", "js-sys", + "sha2 0.10.9", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 0e814bb9..58f1f4de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ rust-embed = "8" inventory = "0.3" truck-shapeops = { version = "0.4", optional = true } rustc-hash = "2" +sha2 = "0.10" fontdb = "0.23" ttf-parser = "0.25" cosmic-text = "0.15" @@ -61,7 +62,7 @@ windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_Window ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } [patch.crates-io] -acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "78b57b0" } +acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "6f9a92f" } iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } diff --git a/build.rs b/build.rs index 80230dcb..5c616ab2 100644 --- a/build.rs +++ b/build.rs @@ -7,6 +7,53 @@ use std::path::Path; fn main() { + println!("cargo:rerun-if-changed=.git/HEAD"); + if let Ok(head) = std::fs::read_to_string(".git/HEAD") { + if let Some(reference) = head.trim().strip_prefix("ref: ") { + println!("cargo:rerun-if-changed=.git/{reference}"); + } + } + let revision = std::process::Command::new("git") + .args(["rev-parse", "--short=12", "HEAD"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + let dirty = std::process::Command::new("git") + .args(["diff-index", "--quiet", "HEAD", "--"]) + .status() + .ok() + .is_some_and(|status| !status.success()); + let revision = if dirty { + format!("{revision}-dirty") + } else { + revision + }; + println!("cargo:rustc-env=OCS_GIT_REV={revision}"); + println!( + "cargo:rustc-env=OCS_BUILD_PROFILE={}", + std::env::var("PROFILE").unwrap_or_else(|_| "unknown".to_string()) + ); + let mut features: Vec = std::env::vars() + .filter_map(|(name, value)| { + (value == "1") + .then(|| name.strip_prefix("CARGO_FEATURE_").map(str::to_owned)) + .flatten() + }) + .collect(); + features.sort(); + println!( + "cargo:rustc-env=OCS_BUILD_FEATURES={}", + if features.is_empty() { + "none".to_string() + } else { + features.join(",") + } + ); + // The Patreon token is baked in at compile time via `option_env!` in // src/patreon.rs. `option_env!` is not tracked by Cargo, so without this a // token change wouldn't trigger a rebuild — declare the dependency so an diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index a6a957f8..2eb877b8 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -13,4 +13,5 @@ bincode = "1.3" console_error_panic_hook = "0.1" getrandom = { version = "0.3", features = ["wasm_js"] } js-sys = "0.3" +sha2 = "0.10" wasm-bindgen = "0.2" diff --git a/crates/ocs_web_worker/src/lib.rs b/crates/ocs_web_worker/src/lib.rs index 9263f963..5845100b 100644 --- a/crates/ocs_web_worker/src/lib.rs +++ b/crates/ocs_web_worker/src/lib.rs @@ -1,10 +1,15 @@ use std::io::Cursor; +use std::sync::Arc; use acadrust::io::dwg::DwgReader; -use acadrust::DxfReader; +use acadrust::{DwgReadOptions, DxfReader, DxfReaderConfiguration}; use js_sys::{Function, Uint8Array}; +use sha2::{Digest, Sha256}; use wasm_bindgen::prelude::*; +const HASH_MARKER: &str = "\nreport-source-sha256:"; +const PROTOCOL_VERSION: u16 = 3; + /// Parse DWG/DXF on a dedicated browser worker and return a compact serialized /// document. The main wasm instance only deserializes and installs it, so the /// expensive bit/handle/object decode never occupies the browser UI thread. @@ -12,30 +17,175 @@ use wasm_bindgen::prelude::*; pub fn parse_document( name: String, bytes: Uint8Array, + recovery_mode: bool, + initial_error: String, report_stage: &Function, ) -> Result { console_error_panic_hook::set_once(); report_stage.call1(&JsValue::NULL, &JsValue::from_str("copy input"))?; - let bytes = bytes.to_vec(); + let bytes: Arc<[u8]> = Arc::from(bytes.to_vec()); report_stage.call1(&JsValue::NULL, &JsValue::from_str("parse document"))?; let ext = name.rsplit('.').next().unwrap_or_default().to_lowercase(); - let document = match ext.as_str() { - "dwg" => DwgReader::from_stream(Cursor::new(bytes)) - .read() - .map_err(|error| JsValue::from_str(&error.to_string()))?, - "dxf" => DxfReader::from_reader(Cursor::new(bytes)) - .map_err(|error| JsValue::from_str(&error.to_string()))? - .read() - .map_err(|error| JsValue::from_str(&error.to_string()))?, - _ => { - return Err(JsValue::from_str(&format!( - "Unsupported file format: .{ext}" - ))) + if !matches!(ext.as_str(), "dwg" | "dxf") { + return encode_result( + Err((format!("Unsupported file format: .{ext}"), None)), + None, + false, + &bytes, + ); + } + let outcome_result = match ext.as_str() { + "dwg" => { + if recovery_mode { + DwgReader::from_stream_with_options( + Cursor::new(Arc::clone(&bytes)), + DwgReadOptions::failsafe(), + ) + .read_with_stats() + } else { + DwgReader::from_stream(Cursor::new(Arc::clone(&bytes))) + .read_with_stats() + } + } + "dxf" => { + if recovery_mode { + DxfReader::from_reader(Cursor::new(Arc::clone(&bytes))) + .and_then(|reader| { + reader + .with_configuration(DxfReaderConfiguration { + failsafe: true, + ..DxfReaderConfiguration::default() + }) + .read_with_stats() + }) + } else { + DxfReader::from_reader(Cursor::new(Arc::clone(&bytes))) + .and_then(|reader| reader.read_with_stats()) + } + } + _ => unreachable!(), + }; + let mut outcome = match outcome_result { + Ok(outcome) => outcome, + Err(error) => { + let recoverable_parse_error = !recovery_mode && recoverable_reader_error(&error); + let source_sha256 = recovery_mode.then(|| sha256_document_bytes(&bytes)); + return encode_result( + Err((error.to_string(), None)), + source_sha256, + recoverable_parse_error, + &bytes, + ); } }; + if !outcome.stats.has_usable_drawing_data() { + let error = if recovery_mode { + format!( + "initial read failed: {initial_error}; recovery found no usable drawing data" + ) + } else { + "initial read returned no source drawing records".to_string() + }; + let source_sha256 = recovery_mode.then(|| sha256_document_bytes(&bytes)); + return encode_result( + Err((error, Some(outcome.stats))), + source_sha256, + !recovery_mode, + &bytes, + ); + } + if !recovery_mode && report_fingerprint_needed(&outcome.stats) { + let message = outcome + .stats + .diagnostics + .first() + .map(|diagnostic| diagnostic.message.clone()) + .unwrap_or_else(|| "normal read detected recoverable drawing errors".to_string()); + return encode_result(Err((message, Some(outcome.stats))), None, true, &bytes); + } + if recovery_mode { + outcome.document.notifications.notify( + acadrust::notification::NotificationType::Error, + format!("Initial read failed; recovery mode continued: {initial_error}"), + ); + acadrust::push_read_diagnostic( + &mut outcome.stats.diagnostics, + acadrust::ReadDiagnostic::new( + "strict-read-failed", + acadrust::ReadStage::RecordStream, + initial_error, + ), + ); + outcome.stats.recovered_errors = outcome.stats.recovered_errors.saturating_add(1); + } + let source_sha256 = report_fingerprint_needed(&outcome.stats) + .then(|| sha256_document_bytes(&bytes)); report_stage.call1(&JsValue::NULL, &JsValue::from_str("serialize document"))?; - let encoded = - bincode::serialize(&document).map_err(|error| JsValue::from_str(&error.to_string()))?; + let encoded = encode_result(Ok(outcome), source_sha256, false, &bytes)?; report_stage.call1(&JsValue::NULL, &JsValue::from_str("copy output"))?; + Ok(encoded) +} + +#[wasm_bindgen] +pub fn sha256_document(bytes: Uint8Array) -> String { + sha256_document_bytes(&bytes.to_vec()) +} + +fn report_fingerprint_needed(stats: &acadrust::ReadStats) -> bool { + stats.recovered() || stats.skipped_source_records > 0 || !stats.stream_completed +} + +fn recoverable_reader_error(error: &acadrust::DxfError) -> bool { + matches!( + error, + acadrust::DxfError::Compression(_) + | acadrust::DxfError::Parse(_) + | acadrust::DxfError::InvalidDxfCode(_) + | acadrust::DxfError::InvalidHandle(_) + | acadrust::DxfError::ObjectNotFound(_) + | acadrust::DxfError::InvalidEntityType(_) + | acadrust::DxfError::ChecksumMismatch { .. } + | acadrust::DxfError::InvalidHeader(_) + | acadrust::DxfError::InvalidFormat(_) + | acadrust::DxfError::InvalidSentinel(_) + | acadrust::DxfError::Decompression(_) + | acadrust::DxfError::Encoding(_) + ) +} + +fn encode_result( + result: Result)>, + source_sha256: Option, + recoverable_parse_error: bool, + bytes: &[u8], +) -> Result { + let encoded = bincode::serialize(&( + PROTOCOL_VERSION, + result, + source_sha256, + recoverable_parse_error, + )) + .map_err(|error| worker_error(error.to_string(), bytes, true))?; Ok(Uint8Array::from(encoded.as_slice())) } + +fn worker_error(error: String, bytes: &[u8], include_fingerprint: bool) -> JsValue { + if include_fingerprint { + JsValue::from_str(&format!( + "{error}{HASH_MARKER}{}", + sha256_document_bytes(bytes) + )) + } else { + JsValue::from_str(&error) + } +} + +fn sha256_document_bytes(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut output = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write; + let _ = write!(output, "{byte:02x}"); + } + output +} diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 2820ec1b..9aa6e0b1 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -92,6 +92,29 @@ modal-unsaved-changes = Unsaved Changes modal-point-style = Point Style modal-attribute-editor = Attribute Editor modal-save-drawing-as = Save Drawing As +modal-recovery-report = Drawing Recovery Report +modal-recovery-prompt = Drawing Recovery + +recovery-opened-with-repairs = Drawing opened with a recovery report +recovery-open-failed = Drawing could not be opened +recovery-repaired-description = Problems were detected while opening the drawing. Review the report; if drawing data was repaired, save it as a new file. +recovery-failed-description = Recovery could not produce usable drawing data. Review the report and try a backup or automatic-save copy. +recovery-entities-checked = Entities checked +recovery-issues-found = Issues found +recovery-entities-removed = Entities removed +recovery-references-checked = References checked +recovery-referenced-entities-removed = Referenced entities removed +recovery-references-unavailable = References unavailable +recovery-log-path = Recovery log +recovery-log-write-failed = Recovery log could not be written +recovery-log-download-ready = The recovery report is ready to download. +recovery-save-copy = Save Repaired Copy +recovery-show-log = Show Recovery Log +recovery-save-new-file-required = A repaired drawing must be saved to a new file. +recovery-prompt-heading = Normal opening failed +recovery-prompt-description = The drawing was not changed. Recovery mode can try to salvage usable data, but damaged records may be skipped. Do you want to continue? +recovery-attempt = Try Recovery +recovery-decline = Cancel command-move-base = MOVE Specify base point [{ $count -> diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index d8b092a1..dea870c5 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -92,6 +92,29 @@ modal-unsaved-changes = Kaydedilmemiş Değişiklikler modal-point-style = Nokta Stili modal-attribute-editor = Öznitelik Düzenleyicisi modal-save-drawing-as = Çizimi Farklı Kaydet +modal-recovery-report = Çizim Kurtarma Raporu +modal-recovery-prompt = Çizim Kurtarma + +recovery-opened-with-repairs = Çizim kurtarma raporuyla açıldı +recovery-open-failed = Çizim açılamadı +recovery-repaired-description = Çizim açılırken sorunlar algılandı. Raporu inceleyin; çizim verileri onarıldıysa yeni bir dosya olarak kaydedin. +recovery-failed-description = Kurtarma işlemi kullanılabilir çizim verisi üretemedi. Raporu inceleyip bir yedek veya otomatik kayıt kopyasını deneyin. +recovery-entities-checked = Denetlenen obje +recovery-issues-found = Bulunan sorun +recovery-entities-removed = Kaldırılan obje +recovery-references-checked = Denetlenen referans +recovery-referenced-entities-removed = Referanslardan kaldırılan obje +recovery-references-unavailable = Kullanılamayan referans +recovery-log-path = Kurtarma günlüğü +recovery-log-write-failed = Kurtarma günlüğü yazılamadı +recovery-log-download-ready = Kurtarma raporu indirilmeye hazır. +recovery-save-copy = Onarılan Kopyayı Kaydet +recovery-show-log = Kurtarma Günlüğünü Göster +recovery-save-new-file-required = Onarılan çizim yeni bir dosyaya kaydedilmelidir. +recovery-prompt-heading = Normal açılış başarısız oldu +recovery-prompt-description = Çizim değiştirilmedi. Kurtarma modu kullanılabilir verileri çıkarmayı deneyebilir; bozuk kayıtlar atlanabilir. Devam etmek istiyor musunuz? +recovery-attempt = Kurtarmayı Dene +recovery-decline = İptal command-move-base = MOVE Temel noktayı belirtin [{ $count } nesne]: command-move-target = MOVE Hedef noktayı belirtin [temel { $x },{ $y }]: diff --git a/src/app/automation.rs b/src/app/automation.rs index b16905cd..65a54118 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -898,11 +898,22 @@ mod tests { ); assert_eq!(app.pending_opens.front(), Some(&b)); - // A drawing that fails to parse must still release the queue behind it. - let _ = app.update(Message::FileOpened(Err("boom".into()))); + // A failed drawing pauses the queue while its recovery report is shown. + let open_id = app.opening.as_ref().map(|opening| opening.id).unwrap(); + let _ = app.update(Message::FileOpened(open_id, Err("boom".into()))); + assert!( + app.active_modal == Some(crate::app::ModalKind::Recovery), + "failed open should show its recovery report" + ); + assert_eq!( + app.pending_opens.len(), + 1, + "queued drawing should wait until the report is acknowledged" + ); + let _ = app.update(Message::RecoveryClose); assert!( app.pending_opens.is_empty(), - "a failed open must drain the queue, not strand it" + "closing the report must release the queued drawing" ); let _ = std::fs::remove_file(&a); diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 49cf4266..8351bba9 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -550,12 +550,24 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("XREF Reloaded \"{}\"", info.name).as_ref()); } + crate::io::xref::XrefStatus::Recovered => { + self.command_line.push_error(crate::tf!( + "XREF Reloaded with repairs: \"{}\"", + info.name + ).as_ref()); + } crate::io::xref::XrefStatus::NotFound => { self.command_line.push_error(crate::tf!( "XREF Not found: \"{}\" ({})", info.name, info.path ).as_ref()); } + crate::io::xref::XrefStatus::Failed => { + self.command_line.push_error(crate::tf!( + "XREF Reload failed: \"{}\" ({})", + info.name, info.path + ).as_ref()); + } crate::io::xref::XrefStatus::Unloaded => { self.command_line.push_info(crate::tf!( "XREF Unloaded (skipped): \"{}\"", diff --git a/src/app/document.rs b/src/app/document.rs index e9c283f7..e4fd7db5 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -121,6 +121,9 @@ pub(super) struct DocumentTab { #[cfg(not(target_arch = "wasm32"))] pub(super) disk_fingerprint: Option, pub(super) dirty: bool, + /// Direct Save is redirected to Save As after open-time repairs so the + /// source drawing cannot be overwritten accidentally. + pub(super) recovery_save_as_required: bool, /// Monotonic committed-edit/undo/redo revision. Background save completion /// uses it with scene epochs so an older snapshot never clears newer work. pub(super) edit_revision: u64, @@ -434,6 +437,7 @@ impl DocumentTab { #[cfg(not(target_arch = "wasm32"))] disk_fingerprint: None, dirty: false, + recovery_save_as_required: false, edit_revision: 0, prev_selection: Vec::new(), tab_title: format!("Drawing{}", n), diff --git a/src/app/mod.rs b/src/app/mod.rs index 5c9665d0..26872969 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -206,10 +206,16 @@ pub const OPEN_PHASE_FINALIZING: u8 = 4; #[derive(Debug, Clone)] pub struct OpenProgress { + pub id: u64, pub name: String, + pub source_path: Option, pub size_bytes: u64, pub state: Arc, pub started: Instant, + pub recovery_error: Option, + pub recovery_read_stats: Option, + #[cfg(target_arch = "wasm32")] + pub recovery_bytes: Option>, /// Disk state captured before parsing starts. If another editor changes the /// file while it loads, the first Save must not silently overwrite it. #[cfg(not(target_arch = "wasm32"))] @@ -816,6 +822,9 @@ pub(super) struct OpenCADStudio { /// `Some` while a CAD file is loading — drives the modal overlay. /// Cleared when the load finishes, errors, or the user cancels. pub(super) opening: Option, + open_job_serial: u64, + /// Last repair or failed-open report shown in the recovery modal. + recovery_report: Option, /// Drawings handed to us by other launches while `opening` was busy. /// `opening` is a single slot that a second `OpenPathPicked` would /// overwrite, and `on_file_opened` drops any result arriving once it is @@ -1409,6 +1418,8 @@ pub enum ModalKind { DimStyle, Unsaved, SaveDialog, + Recovery, + RecoveryPrompt, Options, FindReplace, AecDropWarning, @@ -1603,7 +1614,19 @@ pub enum Message { /// User clicked Cancel on the loading overlay. The parser thread keeps /// running but its result is discarded. OpenCancel, - FileOpened(Result<(String, PathBuf, CadDocument, crate::scene::DerivedCaches), String>), + #[cfg(target_arch = "wasm32")] + WebFileOpened(u64, crate::io::WebOpenOutcome), + #[cfg(target_arch = "wasm32")] + WebFileCached(u64, crate::io::WebOpenOutcome, Result<(), String>), + FileOpened(u64, Result< + (String, PathBuf, CadDocument, crate::scene::DerivedCaches), + crate::io::OpenLoadError, + >), + RecoveryClose, + RecoveryAttempt, + RecoveryDecline, + RecoverySaveAs, + RecoveryShowLog, /// Web: an asynchronous OPFS copy written after Save is ready for recents. #[cfg(target_arch = "wasm32")] WebRecentStored(Result), @@ -2867,6 +2890,8 @@ impl OpenCADStudio { plot_dialog: crate::ui::window::plot::PlotDialogState::default(), plot_prev: None, opening: None, + open_job_serial: 0, + recovery_report: None, pending_opens: std::collections::VecDeque::new(), active_interaction_index: None, queued_interaction_indices: std::collections::VecDeque::new(), diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 4a143d63..88622e52 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -29,12 +29,8 @@ impl OpenCADStudio { // re-targeting it. A new/unsaved drawing has no source format, so it // uses the application-wide default chosen in Options (#529). self.save_dialog_format = if let Some(path) = &self.tabs[tab_idx].current_path { - let is_dxf = path - .extension() - .and_then(|e| e.to_str()) - .map(|e| e.eq_ignore_ascii_case("dxf")) - .unwrap_or(false); let document = &self.tabs[tab_idx].scene.document; + let is_dxf = crate::io::source_is_dxf(Some(path), document); let version = if is_dxf { document.version } else { @@ -55,6 +51,14 @@ impl OpenCADStudio { let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format); self.save_dialog_filename = format!("{}.{ext}", self.tabs[tab_idx].tab_display_name()); } + if self.tabs[tab_idx].recovery_save_as_required { + let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format); + let stem = std::path::Path::new(&self.save_dialog_filename) + .file_stem() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| self.tabs[tab_idx].tab_display_name()); + self.save_dialog_filename = format!("{stem}_recovered.{ext}"); + } self.aec_drop_acknowledged = false; self.active_modal = Some(crate::app::ModalKind::SaveDialog); Task::none() @@ -269,20 +273,22 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven return Task::none(); } - if let Some(path) = self.tabs[idx].current_path.clone() { - let version = self.tabs[idx].scene.document.version; - self.prepare_native_save(idx); - let close = self.close_unsaved_dialog_window(); - let save = self.queue_native_save( - idx, - path, - version, - crate::app::SavePurpose::Manual, - continuation, - false, - true, - ); - return Task::batch([close, save]); + if !self.tabs[idx].recovery_save_as_required { + if let Some(path) = self.tabs[idx].current_path.clone() { + let version = self.tabs[idx].scene.document.version; + self.prepare_native_save(idx); + let close = self.close_unsaved_dialog_window(); + let save = self.queue_native_save( + idx, + path, + version, + crate::app::SavePurpose::Manual, + continuation, + false, + true, + ); + return Task::batch([close, save]); + } } self.active_tab = idx; diff --git a/src/app/update/file.rs b/src/app/update/file.rs index a01574db..d772d733 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -594,16 +594,29 @@ pub(super) fn on_open_file(&mut self) -> Task { let state = std::sync::Arc::new(crate::io::OpenProgressState::new( crate::app::OPEN_PHASE_READING, )); + let open_id = self.next_open_id(); self.opening = Some(crate::app::OpenProgress { + id: open_id, name: "Opening…".into(), + source_path: None, size_bytes: 0, state: state.clone(), started: Instant::now(), + recovery_error: None, + recovery_read_stats: None, + recovery_bytes: None, }); - Task::perform(crate::io::pick_and_load_web(state), Message::FileOpened) + Task::perform(crate::io::pick_and_load_web(state), move |outcome| { + Message::WebFileOpened(open_id, outcome) + }) } } + pub(in crate::app) fn next_open_id(&mut self) -> u64 { + self.open_job_serial = self.open_job_serial.wrapping_add(1).max(1); + self.open_job_serial + } + /// Index of a tab already showing `path`, or `None`. /// /// Compares resolved paths, so the same drawing reached through a symlink, @@ -751,6 +764,17 @@ pub(super) fn on_open_file(&mut self) -> Task { path: std::path::PathBuf, set_current_path: bool, ) -> Result<(), crate::io::SaveFailure> { + let previous_autosave = self.autosave_target(i); + if self.tabs[i].recovery_save_as_required + && self.tabs[i] + .current_path + .as_deref() + .is_some_and(|source| native_paths_match(source, &path)) + { + return Err(crate::io::SaveFailure::other( + "repaired drawing must be saved to a new file", + )); + } let path_changed = self.tabs[i] .current_path .as_deref() @@ -816,7 +840,10 @@ pub(super) fn on_open_file(&mut self) -> Task { destination_lease, ); self.tabs[i].dirty = false; - let _ = std::fs::remove_file(path.with_extension("sv$")); + if set_current_path { + self.tabs[i].recovery_save_as_required = false; + } + let _ = std::fs::remove_file(previous_autosave); Ok(()) } @@ -883,8 +910,36 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::none(); } let open_started = self.opening.as_ref().map(|p| p.started); + let size_bytes = self + .opening + .as_ref() + .map(|progress| progress.size_bytes) + .unwrap_or(0); let timings = caches.timings; let entity_count = doc.entities().count(); + let parser_errors_recovered = caches.read_stats.as_ref().is_some_and(|stats| { + stats.recovered() + || stats.skipped_source_records > 0 + || !stats.stream_completed + }) || doc.notifications.iter().any(|item| { + item.notification_type == acadrust::notification::NotificationType::Error + }); + let reference_recovered = caches + .xrefs + .iter() + .any(|item| item.status == crate::io::xref::XrefStatus::Recovered); + let reference_failed = caches + .xrefs + .iter() + .any(|item| item.status == crate::io::xref::XrefStatus::Failed); + let document_repaired = parser_errors_recovered + || reference_recovered + || caches.corrupt_dropped > 0 + || caches.xref_dropped > 0; + let recovery_needed = document_repaired || reference_failed; + let total_ms = open_started + .map(|started| started.elapsed().as_millis() as u32) + .unwrap_or(0); self.command_line .push_output(crate::tf!("Opened \"{name}\" — {entity_count} entities").as_ref()); if caches.corrupt_dropped > 0 { @@ -905,19 +960,34 @@ pub(super) fn on_open_file(&mut self) -> Task { self.command_line .push_output(crate::tf!("XREF Loaded \"{}\"", info.name).as_ref()); } + crate::io::xref::XrefStatus::Recovered => { + self.command_line.push_error(crate::tf!( + "XREF Recovered with warnings: \"{}\"", + info.name + ).as_ref()); + } crate::io::xref::XrefStatus::NotFound => { self.command_line.push_error(crate::tf!( "XREF Not found: \"{}\" ({})", info.name, info.path ).as_ref()); } + crate::io::xref::XrefStatus::Failed => { + self.command_line.push_error(crate::tf!( + "XREF Recovery failed: \"{}\" ({})", + info.name, info.path + ).as_ref()); + } crate::io::xref::XrefStatus::Unloaded => { self.command_line .push_info(crate::tf!("XREF Unloaded (skipped): \"{}\"", info.name).as_ref()); } } } + #[cfg(not(target_arch = "wasm32"))] let thumbs_task = self.push_recent(path.clone()); + #[cfg(target_arch = "wasm32")] + let thumbs_task = Task::none(); let current_is_empty = { let t = &self.tabs[self.active_tab]; @@ -938,6 +1008,27 @@ pub(super) fn on_open_file(&mut self) -> Task { idx }; + let mut recovery_report = recovery_needed.then(|| { + crate::io::recovery::RecoveryReport::recovered( + self.tabs[i].id, + &path, + size_bytes, + caches.source_sha256.clone(), + caches.read_stats.clone(), + entity_count.saturating_add(caches.corrupt_dropped), + caches.corrupt_dropped, + caches.xref_dropped, + &caches.xrefs, + &doc.notifications, + document_repaired, + timings, + total_ms, + ) + }); + if let Some(report) = recovery_report.as_mut() { + report.persist(); + } + #[cfg(not(target_arch = "wasm32"))] let opened_fingerprint = self .opening @@ -982,9 +1073,6 @@ pub(super) fn on_open_file(&mut self) -> Task { // `total` is wall time from the Open click to here (post-xref, // pre-first-frame); the phase figures are the background-thread // parse/purge/cache spans plus the UI-thread xref resolve. - let total_ms = open_started - .map(|s| s.elapsed().as_millis() as u32) - .unwrap_or(0); self.command_line.push_info(crate::tf!( " parse {}ms · purge {}ms · caches {}ms · xref {}ms · total {}ms", timings.parse_ms, timings.purge_ms, timings.caches_ms, timings.xref_ms, total_ms @@ -1074,7 +1162,8 @@ pub(super) fn on_open_file(&mut self) -> Task { self.adopt_view_display(i); self.sync_render_mode_to_active_tile(i); self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation; - self.tabs[i].dirty = false; + self.tabs[i].dirty = document_repaired; + self.tabs[i].recovery_save_as_required = document_repaired; self.tabs[i].history = crate::app::document::HistoryState::default(); self.refresh_properties(); #[cfg(not(target_arch = "wasm32"))] @@ -1092,7 +1181,13 @@ pub(super) fn on_open_file(&mut self) -> Task { .set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1); } self.opening.take(); - let pending_open_task = self.drain_pending_open(); + let pending_open_task = if let Some(report) = recovery_report { + self.recovery_report = Some(report); + self.active_modal = Some(crate::app::ModalKind::Recovery); + Task::none() + } else { + self.drain_pending_open() + }; Task::batch([thumbs_task, pending_open_task, interaction_task]) } @@ -1216,6 +1311,23 @@ pub(super) fn on_open_file(&mut self) -> Task { } return Task::none(); } + let destination_is_current = self.tabs[i] + .current_path + .as_deref() + .is_some_and(|current| native_paths_match(current, &path)); + if purpose != crate::app::SavePurpose::Autosave + && self.tabs[i].recovery_save_as_required + && destination_is_current + { + self.command_line.push_error_once( + crate::tr!("recovery-save-new-file-required").as_ref(), + ); + self.restore_failed_save_continuation(continuation, i); + self.active_tab = i; + self.save_dialog_for_unsaved = + continuation != crate::app::SaveContinuation::None; + return self.open_save_dialog_window(i); + } if purpose != crate::app::SavePurpose::Autosave && !set_current_path && self.tabs[i].edit_lock_conflict @@ -1241,10 +1353,6 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::none(); } - let destination_is_current = self.tabs[i] - .current_path - .as_deref() - .is_some_and(|current| native_paths_match(current, &path)); if set_current_path && !destination_is_current { match crate::io::edit_lock::EditLease::acquire(&path) { Ok(lease) => { @@ -1320,7 +1428,8 @@ pub(super) fn on_open_file(&mut self) -> Task { self.save_job_serial = self.save_job_serial.wrapping_add(1); let job_id = self.save_job_serial; self.active_save_jobs.insert(tab_id, job_id); - let previous_autosave = set_current_path.then(|| self.autosave_target(i)); + let previous_autosave = + (purpose != crate::app::SavePurpose::Autosave).then(|| self.autosave_target(i)); let backup = purpose != crate::app::SavePurpose::Autosave && self.backup_on_save; let expected_fingerprint = if check_external_change && purpose != crate::app::SavePurpose::Autosave { @@ -1520,6 +1629,9 @@ pub(super) fn on_open_file(&mut self) -> Task { if outcome.set_current_path { self.tabs[i].current_path = Some(outcome.path.clone()); self.tabs[i].scene.document.version = outcome.version; + if outcome.purpose == crate::app::SavePurpose::SaveAs { + self.tabs[i].recovery_save_as_required = false; + } tasks.push(self.push_recent(outcome.path.clone())); } self.refresh_native_edit_guard_after_save( @@ -1530,7 +1642,6 @@ pub(super) fn on_open_file(&mut self) -> Task { ); if snapshot_is_current { self.tabs[i].dirty = false; - let _ = std::fs::remove_file(outcome.path.with_extension("sv$")); } } } @@ -1726,19 +1837,21 @@ pub(super) fn on_open_file(&mut self) -> Task { // Native: save straight to the known path. Web has no path // (downloads instead), so always go through the Save dialog. #[cfg(not(target_arch = "wasm32"))] - if let Some(path) = self.tabs[i].current_path.clone() { - // A direct Save preserves the document's current version. - let ver = self.tabs[i].scene.document.version; - self.prepare_native_save(i); - return self.queue_native_save( - i, - path, - ver, - crate::app::SavePurpose::Manual, - crate::app::SaveContinuation::None, - false, - true, - ); + if !self.tabs[i].recovery_save_as_required { + if let Some(path) = self.tabs[i].current_path.clone() { + // A direct Save preserves the document's current version. + let ver = self.tabs[i].scene.document.version; + self.prepare_native_save(i); + return self.queue_native_save( + i, + path, + ver, + crate::app::SavePurpose::Manual, + crate::app::SaveContinuation::None, + false, + true, + ); + } } self.save_dialog_for_unsaved = false; self.save_with_default_format(i) @@ -1750,7 +1863,21 @@ pub(super) fn on_open_file(&mut self) -> Task { /// save-before-close flow — the version picker is reserved for Save As. pub(in crate::app) fn save_with_default_format(&mut self, tab_idx: usize) -> Task { self.active_tab = tab_idx; - self.save_dialog_format = self.default_save_format.clone(); + self.save_dialog_format = if self.tabs[tab_idx].recovery_save_as_required { + let document = &self.tabs[tab_idx].scene.document; + let is_dxf = crate::io::source_is_dxf( + self.tabs[tab_idx].current_path.as_deref(), + document, + ); + let version = if is_dxf { + document.version + } else { + document.dwg_source_version.unwrap_or(document.version) + }; + crate::io::format_for_version(version, is_dxf) + } else { + self.default_save_format.clone() + }; let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format); self.save_dialog_filename = self.tabs[tab_idx] .current_path @@ -1758,6 +1885,14 @@ pub(super) fn on_open_file(&mut self) -> Task { .and_then(|p| p.file_name()) .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| format!("{}.{ext}", self.tabs[tab_idx].tab_display_name())); + if self.tabs[tab_idx].recovery_save_as_required { + let path = std::path::Path::new(&self.save_dialog_filename); + let stem = path + .file_stem() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "drawing".to_string()); + self.save_dialog_filename = format!("{stem}_recovered.{ext}"); + } self.aec_drop_acknowledged = false; self.on_save_dialog_confirm() } @@ -1851,6 +1986,7 @@ pub(super) fn on_open_file(&mut self) -> Task { 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( @@ -1939,14 +2075,8 @@ pub(super) fn on_open_file(&mut self) -> Task { /// document's source type and version, then save. pub(super) fn on_aec_drop_same_version(&mut self) -> Task { let tab = &self.tabs[self.active_tab]; - let is_dxf = tab - .current_path - .as_ref() - .and_then(|path| path.extension()) - .and_then(|extension| extension.to_str()) - .map(|extension| extension.eq_ignore_ascii_case("dxf")) - .unwrap_or(false); let document = &tab.scene.document; + let is_dxf = crate::io::source_is_dxf(tab.current_path.as_deref(), document); let src = if is_dxf { document.version } else { @@ -1964,20 +2094,27 @@ pub(super) fn on_open_file(&mut self) -> Task { self.on_save_dialog_confirm() } - /// Where the autosave recovery copy for tab `i` lives: beside a saved - /// drawing as `.sv$`, or — for an unsaved drawing with no path yet — - /// under the system temp dir keyed by the tab's display name. + /// Where the autosave recovery copy for tab `i` lives. #[cfg(not(target_arch = "wasm32"))] pub(in crate::app) fn autosave_target(&self, i: usize) -> std::path::PathBuf { match &self.tabs[i].current_path { - Some(p) => p.with_extension("sv$"), + Some(p) => { + let name = p + .file_name() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "drawing".to_string()); + p.with_file_name(format!("{name}.ocs-autosave.sv$")) + } None => { let safe: String = self.tabs[i] .tab_display_name() .chars() .map(|c| if c.is_alphanumeric() { c } else { '_' }) .collect(); - std::env::temp_dir().join(format!("OpenCADStudio_{safe}.sv$")) + std::env::temp_dir().join(format!( + "OpenCADStudio_{safe}_{}.sv$", + self.tabs[i].id + )) } } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 8c7e30e5..65aea658 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -164,6 +164,7 @@ impl OpenCADStudio { self.layer_state_edit_filter.clear(); self.layer_state_edit_color_open = None; } + Some(Recovery) => self.recovery_report = None, _ => {} } // The tool that opened this dialog is done with it now. Keep the @@ -344,15 +345,21 @@ impl OpenCADStudio { let state = std::sync::Arc::new(crate::io::OpenProgressState::new( crate::app::OPEN_PHASE_READING, )); + let open_id = self.next_open_id(); self.opening = Some(crate::app::OpenProgress { + id: open_id, name, + source_path: Some(path.clone()), size_bytes: 0, state: state.clone(), started: Instant::now(), + recovery_error: None, + recovery_read_stats: None, + recovery_bytes: None, }); Task::perform( crate::io::open_recent_web(path, state), - Message::FileOpened, + move |outcome| Message::WebFileOpened(open_id, outcome), ) } } @@ -382,15 +389,13 @@ impl OpenCADStudio { ]), None => Task::none(), }; - // Already open → go to that tab rather than load a second copy - // of the same drawing. Checked before the queue: switching is - // instant and needs no load slot. - if let Some(idx) = self.tab_showing(&path) { - return Task::batch([raise, self.update(Message::TabSwitch(idx))]); - } - if self.opening.is_some() { + if self.opening.is_some() + || self.active_modal == Some(super::ModalKind::Recovery) + { self.pending_opens.push_back(path); raise + } else if let Some(idx) = self.tab_showing(&path) { + Task::batch([raise, self.update(Message::TabSwitch(idx))]) } else { Task::batch([raise, self.update(Message::OpenRecent(path))]) } @@ -513,14 +518,15 @@ impl OpenCADStudio { ).as_ref()); return Task::none(); } - // Already open → switch to its tab; a load in progress → - // queue behind it (multi-file drops arrive one event each). - if let Some(idx) = self.tab_showing(&path) { - return self.update(Message::TabSwitch(idx)); - } - if self.opening.is_some() { + // A load or recovery report owns the open slot; queue another + // drop until that state is acknowledged. + if self.opening.is_some() + || self.active_modal == Some(super::ModalKind::Recovery) + { self.pending_opens.push_back(path); Task::none() + } else if let Some(idx) = self.tab_showing(&path) { + self.update(Message::TabSwitch(idx)) } else { self.update(Message::OpenRecent(path)) } @@ -552,11 +558,18 @@ impl OpenCADStudio { let progress = std::sync::Arc::new(crate::io::OpenProgressState::new( super::OPEN_PHASE_READING, )); + let open_id = self.next_open_id(); self.opening = Some(super::OpenProgress { + id: open_id, name: name.clone(), + source_path: Some(path.clone()), size_bytes, state: progress.clone(), started: Instant::now(), + recovery_error: None, + recovery_read_stats: None, + #[cfg(target_arch = "wasm32")] + recovery_bytes: None, #[cfg(not(target_arch = "wasm32"))] fingerprint: crate::io::edit_lock::FileFingerprint::capture(&path).ok(), @@ -572,7 +585,7 @@ impl OpenCADStudio { ]); Task::perform( crate::io::open_path_with_phase(path, progress, model_bg), - Message::FileOpened, + move |result| Message::FileOpened(open_id, result), ) } @@ -584,16 +597,105 @@ impl OpenCADStudio { self.drain_pending_open() } - Message::FileOpened(Ok((name, path, doc, caches))) => { + #[cfg(target_arch = "wasm32")] + Message::WebFileOpened(open_id, mut outcome) => { + if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) { + return Task::none(); + } + if let Some(opening) = self.opening.as_mut() { + opening.name = outcome.name.clone(); + opening.source_path = Some(std::path::PathBuf::from(&outcome.name)); + if outcome.size_bytes > 0 || opening.size_bytes == 0 { + opening.size_bytes = outcome.size_bytes; + } + opening.recovery_bytes = outcome.recovery_bytes.take(); + } + if let Some(bytes) = outcome.cache_bytes.take() { + let name = outcome.name.clone(); + return Task::perform( + async move { + let result = + crate::io::web_recent::store_open(&name, bytes, open_id).await; + (outcome, result) + }, + move |(outcome, result)| { + Message::WebFileCached(open_id, outcome, result) + }, + ); + } + let recent_task = if outcome.record_recent && outcome.result.is_ok() { + self.push_recent(std::path::PathBuf::from(&outcome.name)) + } else { + Task::none() + }; + let opened_task = self.update(Message::FileOpened(open_id, outcome.result)); + Task::batch([recent_task, opened_task]) + } + + #[cfg(target_arch = "wasm32")] + Message::WebFileCached(open_id, outcome, cache_result) => { + if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) { + return Task::none(); + } + let recent_task = match cache_result { + Ok(()) => self.push_recent(std::path::PathBuf::from(&outcome.name)), + Err(error) => { + self.command_line.push_error(crate::tf!( + "Opened drawing, but recent copy could not be stored: {error}" + ).as_ref()); + Task::none() + } + }; + let opened_task = self.update(Message::FileOpened(open_id, outcome.result)); + Task::batch([recent_task, opened_task]) + } + + Message::FileOpened(open_id, Ok((name, path, doc, caches))) => { + if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) { + return Task::none(); + } self.on_file_opened(name, path, doc, caches) } - Message::FileOpened(Err(e)) => { + Message::FileOpened(open_id, Err(e)) => { + if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) { + return Task::none(); + } + if e.recovery_available { + if let Some(opening) = self.opening.as_mut() { + opening.recovery_error = Some(e.message); + opening.recovery_read_stats = e.read_stats; + self.active_modal = Some(super::ModalKind::RecoveryPrompt); + return Task::none(); + } + } // If the user cancelled, the overlay was already cleared and // we suppress the noise. - let was_open = self.opening.take().is_some(); - if was_open && e != "Cancelled" { + let opening = self.opening.take(); + if let Some(opening) = opening.filter(|_| e.message != "Cancelled") { self.command_line.push_error(crate::tf!("Open failed: {e}").as_ref()); + let total_ms = opening.started.elapsed().as_millis() as u32; + let failure_phase = crate::io::open_phase_name( + opening + .state + .phase + .load(std::sync::atomic::Ordering::Acquire), + ) + .to_string(); + let mut report = crate::io::recovery::RecoveryReport::failed( + opening.source_path, + opening.name, + opening.size_bytes, + e.source_sha256, + e.read_stats, + failure_phase, + e.message, + total_ms, + ); + report.persist(); + self.recovery_report = Some(report); + self.active_modal = Some(super::ModalKind::Recovery); + return Task::none(); } // A drawing that fails to parse must not strand the ones queued // behind it. @@ -1020,6 +1122,9 @@ impl OpenCADStudio { } Message::TabSwitch(idx) => { + if self.active_modal == Some(super::ModalKind::Recovery) { + return Task::none(); + } self.layout_list_open = false; self.layout_rename_state = None; if idx < self.tabs.len() { @@ -4345,7 +4450,159 @@ impl OpenCADStudio { } Message::CloseModal => { + if self.active_modal == Some(super::ModalKind::RecoveryPrompt) { + return self.update(Message::RecoveryDecline); + } + let resume_open_queue = self.active_modal == Some(super::ModalKind::Recovery); self.close_active_modal(); + if resume_open_queue { + self.drain_pending_open() + } else { + Task::none() + } + } + Message::RecoveryClose => { + self.close_active_modal(); + self.drain_pending_open() + } + Message::RecoveryAttempt => { + let open_id = self.next_open_id(); + let Some(opening) = self.opening.as_mut() else { + self.close_active_modal(); + return Task::none(); + }; + let model_bg = self.default_bg_color.unwrap_or([ + 33.0 / 255.0, + 40.0 / 255.0, + 48.0 / 255.0, + 1.0, + ]); + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = opening.source_path.clone() { + let current_fingerprint = + crate::io::edit_lock::FileFingerprint::capture(&path).ok(); + if current_fingerprint.as_ref() != opening.fingerprint.as_ref() { + let progress = std::sync::Arc::new(crate::io::OpenProgressState::new( + super::OPEN_PHASE_READING, + )); + opening.id = open_id; + opening.state = progress.clone(); + opening.started = Instant::now(); + opening.recovery_error = None; + opening.recovery_read_stats = None; + opening.fingerprint = current_fingerprint; + opening.size_bytes = std::fs::metadata(&path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + self.close_active_modal(); + return Task::perform( + crate::io::open_path_with_phase(path, progress, model_bg), + move |result| Message::FileOpened(open_id, result), + ); + } + } + let Some(initial_error) = opening.recovery_error.take() else { + self.close_active_modal(); + return Task::none(); + }; + let initial_stats = opening.recovery_read_stats.take(); + let Some(path) = opening.source_path.clone() else { + self.close_active_modal(); + return Task::none(); + }; + let progress = std::sync::Arc::new(crate::io::OpenProgressState::new( + super::OPEN_PHASE_READING, + )); + opening.id = open_id; + opening.state = progress.clone(); + opening.started = Instant::now(); + #[cfg(target_arch = "wasm32")] + let recovery_bytes = opening.recovery_bytes.take(); + self.close_active_modal(); + #[cfg(not(target_arch = "wasm32"))] + { + Task::perform( + crate::io::recover_path_with_phase( + path, + progress, + model_bg, + initial_error, + initial_stats, + ), + move |result| Message::FileOpened(open_id, result), + ) + } + #[cfg(target_arch = "wasm32")] + { + let _ = model_bg; + let Some(bytes) = recovery_bytes else { + self.opening = None; + return self.drain_pending_open(); + }; + Task::perform( + crate::io::recover_web_bytes( + path.to_string_lossy().into_owned(), + bytes, + progress, + initial_error, + initial_stats, + ), + move |outcome| Message::WebFileOpened(open_id, outcome), + ) + } + } + Message::RecoveryDecline => { + let declined = self.opening.take(); + self.close_active_modal(); + if let Some(opening) = declined { + self.command_line.push_info(crate::tf!( + "Recovery cancelled: \"{}\"", + opening.name + ).as_ref()); + } + self.drain_pending_open() + } + Message::RecoverySaveAs => { + if !self.pending_opens.is_empty() { + self.close_active_modal(); + return self.drain_pending_open(); + } + let Some(tab_id) = self + .recovery_report + .as_ref() + .and_then(|report| report.tab_id) + else { + self.close_active_modal(); + return Task::none(); + }; + let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else { + self.close_active_modal(); + return Task::none(); + }; + self.close_active_modal(); + self.active_tab = i; + self.open_save_dialog_window(i) + } + Message::RecoveryShowLog => { + let Some(report) = self.recovery_report.as_ref() else { + return Task::none(); + }; + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(path) = &report.log_path { + if let Err(error) = crate::sys::reveal_in_file_manager(path) { + self.command_line.push_error(crate::tf!( + "Could not show recovery log: {error}" + ).as_ref()); + } + } + } + #[cfg(target_arch = "wasm32")] + { + let name = report.suggested_download_name(); + let body = report.log_text(); + crate::sys::download_bytes(&name, body.as_bytes()); + } Task::none() } Message::AttrEditorOpen(handle) => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 386c0513..3a9e4e2a 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1565,7 +1565,11 @@ impl OpenCADStudio { iced::widget::Space::new().width(0).height(0).into() }; - let open_progress_layer: Element<'_, Message> = if let Some(p) = &self.opening { + let open_progress_layer: Element<'_, Message> = if let Some(p) = self + .opening + .as_ref() + .filter(|progress| progress.recovery_error.is_none()) + { crate::ui::window::open_progress::view(p, iced::time::Instant::now()) } else { iced::widget::Space::new().width(0).height(0).into() @@ -1641,7 +1645,10 @@ impl OpenCADStudio { // (currently just the open-progress indicator). Without this gate the // app burned 2-3% CPU continuously redrawing an unchanged view. // See #18. - let needs_frames = self.opening.is_some(); + let needs_frames = self + .opening + .as_ref() + .is_some_and(|progress| progress.recovery_error.is_none()); let frames = if needs_frames { window::frames().map(Message::Tick) } else { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 4082e98c..44cf2753 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -41,6 +41,8 @@ impl OpenCADStudio { Some(K::PointStyle) => crate::tr!("modal-point-style"), Some(K::AttributeEditor) => crate::tr!("modal-attribute-editor"), Some(K::SaveDialog) => crate::tr!("modal-save-drawing-as"), + Some(K::Recovery) => crate::tr!("modal-recovery-report"), + Some(K::RecoveryPrompt) => crate::tr!("modal-recovery-prompt"), None => String::new(), } } @@ -1289,6 +1291,27 @@ impl OpenCADStudio { ) }) } + super::super::ModalKind::Recovery => { + let report = self.recovery_report.as_ref()?; + sized_flow(ex, 680, 460, |flow| { + crate::ui::window::recovery::view_window( + report, + self.pending_opens.is_empty(), + flow, + ) + }) + } + super::super::ModalKind::RecoveryPrompt => { + let opening = self.opening.as_ref()?; + let error = opening.recovery_error.as_deref()?; + automatic_flow(ex, |flow| { + crate::ui::window::recovery::view_prompt( + &opening.name, + error, + flow, + ) + }) + } }) } } diff --git a/src/io/mod.rs b/src/io/mod.rs index d879a303..a6d1c608 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -12,6 +12,7 @@ pub mod single_instance; pub mod pdf_export; pub mod plot_style; pub mod print_to_printer; +pub mod recovery; pub mod step; pub mod stl; pub mod xref; @@ -28,7 +29,9 @@ pub(crate) mod web_recent; use crate::scene::DerivedCaches; use acadrust::entities::EntityType; use acadrust::io::dwg::DwgReader; -use acadrust::{CadDocument, DwgWriter, DxfReader, DxfWriter}; +use acadrust::{ + CadDocument, DwgReadOptions, DwgWriter, DxfReader, DxfReaderConfiguration, DxfWriter, +}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU16, AtomicU32, AtomicU8, Ordering}; use std::sync::Arc; @@ -75,6 +78,82 @@ impl OpenProgressState { } } +pub fn open_phase_name(phase: u8) -> &'static str { + match phase { + crate::app::OPEN_PHASE_READING => "reading", + crate::app::OPEN_PHASE_PARSING => "parsing", + crate::app::OPEN_PHASE_XREF => "references", + crate::app::OPEN_PHASE_CACHING => "derived-caches", + crate::app::OPEN_PHASE_FINALIZING => "finalizing", + _ => "unknown", + } +} + +fn recovery_fingerprint_needed(caches: &DerivedCaches) -> bool { + let parser_issue = caches.read_stats.as_ref().is_some_and(|stats| { + stats.recovered() + || stats.skipped_source_records > 0 + || !stats.stream_completed + }); + let reference_issue = caches.xrefs.iter().any(|item| { + matches!( + item.status, + crate::io::xref::XrefStatus::Recovered | crate::io::xref::XrefStatus::Failed + ) + }); + parser_issue || caches.corrupt_dropped > 0 || caches.xref_dropped > 0 || reference_issue +} + +#[derive(Debug, Clone)] +pub struct OpenLoadError { + pub message: String, + pub source_sha256: Option, + pub read_stats: Option, + pub recovery_available: bool, +} + +impl OpenLoadError { + fn new(message: impl Into, source_sha256: Option) -> Self { + Self { + message: message.into(), + source_sha256, + read_stats: None, + recovery_available: false, + } + } + + #[cfg(not(target_arch = "wasm32"))] + fn recovery_prompt( + message: impl Into, + read_stats: Option, + ) -> Self { + Self { + message: message.into(), + source_sha256: None, + read_stats, + recovery_available: true, + } + } +} + +impl std::fmt::Display for OpenLoadError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl From for OpenLoadError { + fn from(message: String) -> Self { + Self::new(message, None) + } +} + +impl From<&str> for OpenLoadError { + fn from(message: &str) -> Self { + Self::new(message, None) + } +} + // ── Open ────────────────────────────────────────────────────────────────── /// Show the file picker and return the chosen path plus its size in bytes. @@ -101,22 +180,89 @@ pub async fn pick_open_path() -> Option<(PathBuf, u64)> { /// the load. Writes phase markers into `phase` so the UI can show /// "Parsing entities…" / "Building caches…" / "Finalizing…" while the loader /// thread runs. +#[cfg(not(target_arch = "wasm32"))] pub async fn open_path_with_phase( path: PathBuf, progress: Arc, model_bg: [f32; 4], -) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> { +) -> Result<(String, PathBuf, CadDocument, DerivedCaches), OpenLoadError> { + open_path_with_phase_attempt(path, progress, model_bg, OpenAttempt::Strict).await +} + +#[cfg(target_arch = "wasm32")] +pub async fn open_path_with_phase( + _path: PathBuf, + _progress: Arc, + _model_bg: [f32; 4], +) -> Result<(String, PathBuf, CadDocument, DerivedCaches), OpenLoadError> { + Err(OpenLoadError::from( + "filesystem path opening is unavailable on this target", + )) +} + +#[cfg(not(target_arch = "wasm32"))] +pub async fn recover_path_with_phase( + path: PathBuf, + progress: Arc, + model_bg: [f32; 4], + initial_error: String, + initial_stats: Option, +) -> Result<(String, PathBuf, CadDocument, DerivedCaches), OpenLoadError> { + open_path_with_phase_attempt( + path, + progress, + model_bg, + OpenAttempt::Recovery(initial_error, initial_stats), + ) + .await +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug, Clone)] +enum OpenAttempt { + Strict, + Recovery(String, Option), +} + +#[cfg(not(target_arch = "wasm32"))] +struct OpenAttemptFailure { + message: String, + read_stats: Option, + recoverable: bool, +} + +#[cfg(not(target_arch = "wasm32"))] +impl From for OpenAttemptFailure { + fn from(message: String) -> Self { + Self { + message, + read_stats: None, + recoverable: false, + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +async fn open_path_with_phase_attempt( + path: PathBuf, + progress: Arc, + model_bg: [f32; 4], + attempt: OpenAttempt, +) -> Result<(String, PathBuf, CadDocument, DerivedCaches), OpenLoadError> { let name = path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "unknown".into()); let path2 = path.clone(); let progress2 = progress.clone(); + let recovery_available = matches!(attempt, OpenAttempt::Strict); let (sender, receiver) = iced::futures::channel::oneshot::channel(); std::thread::Builder::new() .name("ocs-file-open".to_string()) .spawn(move || { - let result = (|| -> Result<_, String> { + let initial_fingerprint = crate::io::edit_lock::FileFingerprint::capture(&path2).ok(); + let attempted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + (|| -> Result<_, OpenAttemptFailure> { use iced::time::Instant; progress2.set(crate::app::OPEN_PHASE_PARSING, 200, 0, 1000); let t_parse = Instant::now(); @@ -133,12 +279,28 @@ pub async fn open_path_with_phase( }); callback }; - let mut doc = load_file_with_progress(&path2, Some(parser_progress))?; + std::fs::File::open(&path2).map_err(|error| OpenAttemptFailure { + message: format!("failed to open drawing: {error}"), + read_stats: None, + recoverable: false, + })?; + let outcome = load_file_for_open(&path2, Some(parser_progress), &attempt)?; + let read_stats = outcome.stats; + let mut doc = outcome.document; let parse_ms = t_parse.elapsed().as_millis() as u32; progress2.set(crate::app::OPEN_PHASE_PARSING, 5800, 1000, 1000); let t_purge = Instant::now(); let dropped = purge_corrupt_entities(&mut doc); let purge_ms = t_purge.elapsed().as_millis() as u32; + if matches!(attempt, OpenAttempt::Strict) && dropped > 0 { + return Err(OpenAttemptFailure { + message: format!( + "normal read found {dropped} structurally invalid drawing records" + ), + read_stats: Some(read_stats), + recoverable: true, + }); + } progress2.set(crate::app::OPEN_PHASE_XREF, 6000, 0, 1); let t_xref = Instant::now(); let (xref_infos, xref_dropped) = if let Some(base_dir) = path2.parent() { @@ -188,51 +350,143 @@ pub async fn open_path_with_phase( xref_ms, }; caches.corrupt_dropped = dropped; + caches.read_stats = Some(read_stats); caches.xref_dropped = xref_dropped; caches.xrefs = xref_infos; + if recovery_fingerprint_needed(&caches) { + caches.source_sha256 = stable_sha256_file( + &path2, + initial_fingerprint.as_ref(), + ); + } progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9600, 0, 1); let (prepared_doc, prepared_geometry) = crate::scene::prepare_open_geometry(doc, &caches, model_bg); doc = prepared_doc; caches.prepared_geometry = Some(prepared_geometry); progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9950, 1, 1); - Ok((doc, caches)) - })(); + Ok((doc, caches)) + })() + })); + let result = match attempted { + Ok(Ok(value)) => Ok(value), + Ok(Err(failure)) => Err(if recovery_available && failure.recoverable { + OpenLoadError::recovery_prompt(failure.message, failure.read_stats) + } else { + OpenLoadError { + message: failure.message, + source_sha256: stable_sha256_file( + &path2, + initial_fingerprint.as_ref(), + ), + read_stats: failure.read_stats, + recovery_available: false, + } + }), + Err(payload) => { + let message = format!( + "file-open worker panicked: {}", + panic_message(payload.as_ref()) + ); + Err(OpenLoadError { + message, + source_sha256: stable_sha256_file( + &path2, + initial_fingerprint.as_ref(), + ), + read_stats: None, + recovery_available: false, + }) + } + }; let _ = sender.send(result); }) - .map_err(|error| format!("failed to start parser thread: {error}"))?; + .map_err(|error| OpenLoadError::from(format!("failed to start parser thread: {error}")))?; let (doc, caches) = receiver .await - .map_err(|_| "parser thread stopped without a result".to_string())??; + .map_err(|_| OpenLoadError::from("parser thread stopped without a result"))??; Ok((name, path, doc, caches)) } +#[cfg(not(target_arch = "wasm32"))] +fn stable_sha256_file( + path: &Path, + initial: Option<&crate::io::edit_lock::FileFingerprint>, +) -> Option { + let initial = initial?; + let before = crate::io::edit_lock::FileFingerprint::capture(path).ok()?; + if &before != initial { + return None; + } + let digest = crate::io::recovery::sha256_file(path).ok()?; + let after = crate::io::edit_lock::FileFingerprint::capture(path).ok()?; + (after == before).then_some(digest) +} + +#[cfg(not(target_arch = "wasm32"))] +fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + let message = payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| payload.downcast_ref::<&str>().copied()) + .unwrap_or("unknown panic payload"); + message.chars().take(500).collect() +} + /// Web file open: show the browser picker, read the chosen file's bytes, parse /// it, and build the derived caches — producing the same payload as the native /// `open_path_with_phase` so it can feed the existing `Message::FileOpened` /// handler. There is no filesystem path on the web, so a name-only `PathBuf` /// stands in for the document path. +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Clone)] +pub struct WebOpenOutcome { + pub name: String, + pub size_bytes: u64, + pub result: Result<(String, PathBuf, CadDocument, DerivedCaches), OpenLoadError>, + pub recovery_bytes: Option>, + pub cache_bytes: Option>, + pub record_recent: bool, +} + #[cfg(target_arch = "wasm32")] pub async fn pick_and_load_web( progress: Arc, -) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> { - let handle = crate::sys::file_dialog() +) -> WebOpenOutcome { + let Some(handle) = crate::sys::file_dialog() .set_title("Open CAD file") .add_filter("CAD Files", &["dwg", "dxf", "DWG", "DXF"]) .add_filter("All Files", &["*"]) .pick_file() .await - .ok_or_else(|| "Cancelled".to_string())?; + else { + return WebOpenOutcome { + name: "Opening…".to_string(), + size_bytes: 0, + result: Err(OpenLoadError::from("Cancelled")), + recovery_bytes: None, + cache_bytes: None, + record_recent: false, + }; + }; let name = handle.file_name(); progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2); - let bytes = handle.read().await; - let parsed = load_web_bytes(&name, &bytes, progress.clone()); - let cached = web_recent::store(&name, &bytes); - let (result, cache_result) = iced::futures::future::join(parsed, cached).await; - if result.is_err() && cache_result.is_ok() { - let _ = web_recent::remove(&name).await; + let bytes: Arc<[u8]> = Arc::from(handle.read().await); + let size_bytes = bytes.len() as u64; + let result = load_web_bytes(&name, &bytes, progress.clone(), false, "", None).await; + let keep_for_recovery = result + .as_ref() + .err() + .is_some_and(|error| error.recovery_available); + let cache_bytes = result.is_ok().then(|| Arc::clone(&bytes)); + WebOpenOutcome { + name, + size_bytes, + result, + recovery_bytes: keep_for_recovery.then(|| Arc::clone(&bytes)), + cache_bytes, + record_recent: false, } - result } /// Reopen a browser-private recent copy without showing the file picker. @@ -240,21 +494,92 @@ pub async fn pick_and_load_web( pub async fn open_recent_web( path: PathBuf, progress: Arc, -) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> { +) -> WebOpenOutcome { + open_recent_web_attempt(path, progress, false, String::new()).await +} + +#[cfg(target_arch = "wasm32")] +pub async fn recover_web_bytes( + name: String, + bytes: Arc<[u8]>, + progress: Arc, + initial_error: String, + initial_stats: Option, +) -> WebOpenOutcome { + let size_bytes = bytes.len() as u64; + let result = load_web_bytes( + &name, + &bytes, + progress, + true, + &initial_error, + initial_stats, + ) + .await; + WebOpenOutcome { + name, + size_bytes, + result, + recovery_bytes: None, + cache_bytes: None, + record_recent: false, + } +} + +#[cfg(target_arch = "wasm32")] +async fn open_recent_web_attempt( + path: PathBuf, + progress: Arc, + recovery_mode: bool, + initial_error: String, +) -> WebOpenOutcome { let name = path .file_name() .map(|name| name.to_string_lossy().into_owned()) - .ok_or_else(|| "Recent drawing has no file name".to_string())?; - let bytes = web_recent::read(&name) - .await - .map_err(|error| format!("Recent copy unavailable for \"{name}\": {error}"))?; + .unwrap_or_else(|| path.to_string_lossy().into_owned()); + let bytes = match web_recent::read(&name).await { + Ok(bytes) => bytes, + Err(error) => { + return WebOpenOutcome { + name: name.clone(), + size_bytes: 0, + result: Err(OpenLoadError::from(format!( + "Recent copy unavailable for \"{name}\": {error}" + ))), + recovery_bytes: None, + cache_bytes: None, + record_recent: false, + }; + } + }; progress.set( crate::app::OPEN_PHASE_READING, 1000, bytes.len(), bytes.len(), ); - load_web_bytes(&name, &bytes, progress).await + let result = load_web_bytes( + &name, + &bytes, + progress, + recovery_mode, + &initial_error, + None, + ) + .await; + let keep_for_recovery = result + .as_ref() + .err() + .is_some_and(|error| error.recovery_available); + let record_recent = result.is_ok(); + WebOpenOutcome { + name: name.clone(), + size_bytes: bytes.len() as u64, + result, + recovery_bytes: keep_for_recovery.then(|| Arc::from(bytes)), + cache_bytes: None, + record_recent, + } } #[cfg(target_arch = "wasm32")] @@ -262,12 +587,48 @@ async fn load_web_bytes( name: &str, bytes: &[u8], progress: Arc, -) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> { + recovery_mode: bool, + initial_error: &str, + mut initial_stats: Option, +) -> Result<(String, PathBuf, CadDocument, DerivedCaches), OpenLoadError> { progress.set(crate::app::OPEN_PHASE_PARSING, 1000, 0, 1); - let mut doc = match web_worker::parse_document(name, bytes).await { - Ok(document) => document, - Err(error) => return Err(format!("Web parser worker: {error}")), + let (outcome, mut source_sha256) = match web_worker::parse_document( + name, + bytes, + recovery_mode, + initial_error, + ) + .await + { + Ok(result) => result, + Err(error) => { + let recoverable_parse_error = error.recovery_available; + let mut source_sha256 = error.source_sha256; + if recovery_mode && source_sha256.is_none() { + source_sha256 = web_worker::sha256_document(bytes).await.ok(); + } + let read_stats = merge_read_stats(error.read_stats, initial_stats.take()); + let message = if recovery_mode && !error.message.contains("initial read failed:") { + format!( + "initial read failed: {initial_error}; recovery read failed: {}", + error.message + ) + } else { + error.message + }; + return Err(OpenLoadError { + message: format!("Web parser worker: {message}"), + recovery_available: !recovery_mode && recoverable_parse_error, + source_sha256, + read_stats, + }); + } }; + let mut outcome = outcome; + if let Some(initial_stats) = initial_stats.take() { + merge_read_diagnostics(&mut outcome.stats, initial_stats); + } + let mut doc = outcome.document; if name.to_ascii_lowercase().ends_with(".dxf") { fix_dxf_dimension_rotations(&mut doc); fix_dxf_layout_plot_settings(&mut doc); @@ -276,8 +637,23 @@ async fn load_web_bytes( fix_current_style_names(&mut doc); progress.set(crate::app::OPEN_PHASE_CACHING, 7000, 0, 1); let dropped = purge_corrupt_entities(&mut doc); + if !recovery_mode && dropped > 0 { + return Err(OpenLoadError { + message: format!( + "normal read found {dropped} structurally invalid drawing records" + ), + source_sha256: None, + read_stats: Some(outcome.stats), + recovery_available: true, + }); + } let mut caches = crate::scene::build_derived_caches(&doc); caches.corrupt_dropped = dropped; + caches.read_stats = Some(outcome.stats); + if source_sha256.is_none() && recovery_fingerprint_needed(&caches) { + source_sha256 = web_worker::sha256_document(bytes).await.ok(); + } + caches.source_sha256 = source_sha256; progress.set(crate::app::OPEN_PHASE_FINALIZING, 9900, 1, 1); let path = PathBuf::from(name); Ok((name.to_string(), path, doc, caches)) @@ -334,13 +710,181 @@ fn sniff_dwg_or_dxf(path: &Path) -> String { #[cfg(not(target_arch = "wasm32"))] pub fn load_file(path: &Path) -> Result { - load_file_with_progress(path, None) + load_file_with_progress(path, None).map(|outcome| outcome.document) } pub(crate) fn load_file_with_progress( path: &Path, _progress: Option>, -) -> Result { +) -> Result { + let outcome = read_file_attempt(path, _progress, false).map_err(|failure| failure.message)?; + if !outcome.stats.has_usable_drawing_data() { + return Err("initial read returned no source drawing records".to_string()); + } + finalize_loaded_outcome(path, outcome) +} + +#[cfg(not(target_arch = "wasm32"))] +fn load_file_for_open( + path: &Path, + progress: Option>, + attempt: &OpenAttempt, +) -> Result { + let outcome = match attempt { + OpenAttempt::Strict => { + let outcome = read_file_attempt(path, progress, false).map_err(|failure| { + OpenAttemptFailure { + message: failure.message, + read_stats: None, + recoverable: failure.recoverable, + } + })?; + if !outcome.stats.has_usable_drawing_data() { + return Err(OpenAttemptFailure { + message: "initial read returned no source drawing records".to_string(), + read_stats: Some(outcome.stats), + recoverable: true, + }); + } + if outcome.stats.recovered() + || outcome.stats.skipped_source_records > 0 + || !outcome.stats.stream_completed + { + let message = outcome + .stats + .diagnostics + .first() + .map(|diagnostic| diagnostic.message.clone()) + .unwrap_or_else(|| { + "normal read detected recoverable drawing errors".to_string() + }); + return Err(OpenAttemptFailure { + message, + read_stats: Some(outcome.stats), + recoverable: true, + }); + } + outcome + } + OpenAttempt::Recovery(initial_error, initial_stats) => { + let mut outcome = read_file_attempt(path, progress, true).map_err(|failure| { + OpenAttemptFailure { + message: format!( + "initial read failed: {initial_error}; recovery read failed: {}", + failure.message + ), + read_stats: initial_stats.clone(), + recoverable: false, + } + })?; + if !outcome.stats.has_usable_drawing_data() { + if let Some(initial_stats) = initial_stats.clone() { + merge_read_diagnostics(&mut outcome.stats, initial_stats); + } + return Err(OpenAttemptFailure { + message: format!( + "initial read failed: {initial_error}; recovery found no usable drawing data" + ), + read_stats: Some(outcome.stats), + recoverable: false, + }); + } + outcome.document.notifications.notify( + acadrust::notification::NotificationType::Error, + format!("Initial read failed; recovery mode continued: {initial_error}"), + ); + acadrust::push_read_diagnostic( + &mut outcome.stats.diagnostics, + acadrust::ReadDiagnostic::new( + "strict-read-failed", + acadrust::ReadStage::RecordStream, + initial_error.clone(), + ), + ); + outcome.stats.recovered_errors = outcome.stats.recovered_errors.saturating_add(1); + if let Some(initial_stats) = initial_stats.clone() { + merge_read_diagnostics(&mut outcome.stats, initial_stats); + } + outcome + } + }; + finalize_loaded_outcome(path, outcome).map_err(OpenAttemptFailure::from) +} + +struct ReaderFailure { + message: String, + #[cfg(not(target_arch = "wasm32"))] + recoverable: bool, +} + +impl ReaderFailure { + fn from_reader(error: acadrust::DxfError) -> Self { + Self { + #[cfg(not(target_arch = "wasm32"))] + recoverable: recoverable_reader_error(&error), + message: error.to_string(), + } + } + + fn terminal(message: impl Into) -> Self { + Self { + message: message.into(), + #[cfg(not(target_arch = "wasm32"))] + recoverable: false, + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn recoverable_reader_error(error: &acadrust::DxfError) -> bool { + matches!( + error, + acadrust::DxfError::Compression(_) + | acadrust::DxfError::Parse(_) + | acadrust::DxfError::InvalidDxfCode(_) + | acadrust::DxfError::InvalidHandle(_) + | acadrust::DxfError::ObjectNotFound(_) + | acadrust::DxfError::InvalidEntityType(_) + | acadrust::DxfError::ChecksumMismatch { .. } + | acadrust::DxfError::InvalidHeader(_) + | acadrust::DxfError::InvalidFormat(_) + | acadrust::DxfError::InvalidSentinel(_) + | acadrust::DxfError::Decompression(_) + | acadrust::DxfError::Encoding(_) + ) +} + +fn merge_read_diagnostics( + target: &mut acadrust::ReadStats, + source: acadrust::ReadStats, +) { + for diagnostic in source.diagnostics { + if !target.diagnostics.contains(&diagnostic) { + acadrust::push_read_diagnostic(&mut target.diagnostics, diagnostic); + } + } +} + +#[cfg(target_arch = "wasm32")] +fn merge_read_stats( + primary: Option, + fallback: Option, +) -> Option { + match (primary, fallback) { + (Some(mut primary), Some(fallback)) => { + merge_read_diagnostics(&mut primary, fallback); + Some(primary) + } + (Some(primary), None) => Some(primary), + (None, fallback) => fallback, + } +} + +fn read_file_attempt( + path: &Path, + progress: Option>, + failsafe: bool, +) -> Result { let ext = path .extension() .map(|e| e.to_string_lossy().to_lowercase()) @@ -355,47 +899,69 @@ pub(crate) fn load_file_with_progress( }; match effective.as_str() { - "dwg" => { - #[cfg(not(target_arch = "wasm32"))] - let mut doc = { - let mut reader = DwgReader::from_mmap(path) - .map_err(|e| e.to_string())?; - if let Some(progress) = _progress { - reader.set_progress_callback(progress); - } - reader.read() - .map_err(|e| e.to_string())? - }; - #[cfg(target_arch = "wasm32")] - let mut doc = DwgReader::from_file(path) - .map_err(|e| e.to_string())? - .read() - .map_err(|e| e.to_string())?; - normalize_block_origins(&mut doc); - fix_viewport_status_flags(&mut doc); - fix_current_style_names(&mut doc); - resolve_raster_image_paths(&mut doc, path.parent()); - doc.source_path = Some(path.to_string_lossy().into_owned()); - Ok(doc) - } - "dxf" => { - let mut doc = DxfReader::from_file(path) - .map_err(|e| e.to_string())? - .read() - .map_err(|e| e.to_string())?; - normalize_block_origins(&mut doc); - fix_dxf_dimension_rotations(&mut doc); - fix_dxf_layout_plot_settings(&mut doc); - fix_viewport_status_flags(&mut doc); - fix_current_style_names(&mut doc); - resolve_raster_image_paths(&mut doc, path.parent()); - doc.source_path = Some(path.to_string_lossy().into_owned()); - Ok(doc) - } - _ => Err(format!("Unsupported file format: .{ext}")), + "dwg" => read_dwg_path(path, progress, failsafe), + "dxf" => read_dxf_path(path, failsafe), + _ => Err(ReaderFailure::terminal(format!( + "Unsupported file format: .{ext}" + ))), } } +fn finalize_loaded_outcome( + path: &Path, + mut outcome: acadrust::ReadOutcome, +) -> Result { + let doc = &mut outcome.document; + normalize_block_origins(doc); + if outcome.stats.source_format == Some(acadrust::SourceFormat::Dxf) { + fix_dxf_dimension_rotations(doc); + fix_dxf_layout_plot_settings(doc); + } + fix_viewport_status_flags(doc); + fix_current_style_names(doc); + resolve_raster_image_paths(doc, path.parent()); + doc.source_path = Some(path.to_string_lossy().into_owned()); + Ok(outcome) +} + +fn read_dwg_path( + path: &Path, + progress: Option>, + failsafe: bool, +) -> Result { + let options = if failsafe { + DwgReadOptions::failsafe() + } else { + DwgReadOptions::default() + }; + #[cfg(not(target_arch = "wasm32"))] + let mut reader = { + let mut reader = DwgReader::from_mmap(path).map_err(ReaderFailure::from_reader)?; + reader.options = options; + reader + }; + #[cfg(target_arch = "wasm32")] + let mut reader = DwgReader::from_file_with_options(path, options) + .map_err(ReaderFailure::from_reader)?; + if let Some(progress) = progress { + reader.set_progress_callback(progress); + } + reader + .read_with_stats() + .map_err(ReaderFailure::from_reader) +} + +fn read_dxf_path(path: &Path, failsafe: bool) -> Result { + DxfReader::from_file(path) + .map_err(ReaderFailure::from_reader)? + .with_configuration(DxfReaderConfiguration { + failsafe, + ..DxfReaderConfiguration::default() + }) + .read_with_stats() + .map_err(ReaderFailure::from_reader) +} + /// Canonicalise legacy block-table origins to the post-R10 representation: /// block contents are local around zero and INSERT carries placement. /// @@ -544,6 +1110,19 @@ pub fn canonical_save_format(format: &str) -> &'static str { .unwrap_or(DEFAULT_SAVE_FORMAT) } +pub fn source_is_dxf(path: Option<&Path>, document: &CadDocument) -> bool { + match path + .and_then(Path::extension) + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("dxf") => true, + Some("dwg") => false, + _ => document.dwg_source_version.is_none(), + } +} + /// Parse a format string like "DWG 2013" or "DXF 2007" into /// `(extension, DxfVersion)`. Falls back to ("dwg", AC1032) for unknown strings. pub fn parse_save_format(format: &str) -> (&'static str, acadrust::DxfVersion) { diff --git a/src/io/recovery.rs b/src/io/recovery.rs new file mode 100644 index 00000000..ad94baa0 --- /dev/null +++ b/src/io/recovery.rs @@ -0,0 +1,674 @@ +use std::path::{Path, PathBuf}; + +use crate::io::xref::{XrefInfo, XrefStatus}; +use crate::scene::OpenTimings; +#[cfg(not(target_arch = "wasm32"))] +use sha2::{Digest, Sha256}; + +const REPORT_SCHEMA_VERSION: u16 = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecoveryStatus { + Recovered, + Failed, +} + +#[derive(Debug, Clone)] +pub struct RecoveryReport { + pub report_id: String, + pub status: RecoveryStatus, + pub tab_id: Option, + pub file_name: String, + pub source_path: Option, + pub size_bytes: u64, + pub source_sha256: Option, + pub read_stats: Option, + pub entities_scanned: usize, + pub entities_removed: usize, + pub referenced_entities_removed: usize, + pub parser_errors_recovered: usize, + pub diagnostics: Vec<(String, String)>, + pub references_checked: usize, + pub references_loaded: usize, + pub references_recovered: usize, + pub references_missing: usize, + pub references_failed: usize, + pub references_skipped: usize, + pub reference_details: Vec<(String, String, String, Option)>, + pub reference_stats: Vec<(String, acadrust::ReadStats)>, + pub timings: OpenTimings, + pub total_ms: u32, + pub failure_phase: Option, + pub error: Option, + pub save_as_required: bool, + pub log_path: Option, + pub log_error: Option, + pub created_unix_seconds: u64, +} + +impl RecoveryReport { + pub fn recovered( + tab_id: u64, + path: &Path, + size_bytes: u64, + source_sha256: Option, + read_stats: Option, + entities_scanned: usize, + entities_removed: usize, + referenced_entities_removed: usize, + references: &[XrefInfo], + notifications: &acadrust::notification::NotificationCollection, + save_as_required: bool, + timings: OpenTimings, + total_ms: u32, + ) -> Self { + let references_loaded = references + .iter() + .filter(|item| matches!(item.status, XrefStatus::Loaded | XrefStatus::Recovered)) + .count(); + let references_recovered = references + .iter() + .filter(|item| item.status == XrefStatus::Recovered) + .count(); + let references_missing = references + .iter() + .filter(|item| item.status == XrefStatus::NotFound) + .count(); + let references_failed = references + .iter() + .filter(|item| item.status == XrefStatus::Failed) + .count(); + let references_skipped = references + .iter() + .filter(|item| item.status == XrefStatus::Unloaded) + .count(); + let reference_details = references + .iter() + .enumerate() + .map(|(index, item)| { + let status = match item.status { + XrefStatus::Loaded => "loaded", + XrefStatus::Recovered => "recovered", + XrefStatus::Failed => "failed", + XrefStatus::NotFound => "not found", + XrefStatus::Unloaded => "skipped", + }; + ( + format!("reference-{:02}", index + 1), + private_path_label(&item.path), + status.to_string(), + item.source_sha256.clone(), + ) + }) + .collect(); + let reference_stats = references + .iter() + .enumerate() + .filter_map(|(index, item)| { + let mut stats = item.read_stats.clone()?; + for diagnostic in &mut stats.diagnostics { + diagnostic.message = redact_known_paths( + &diagnostic.message, + path, + references, + ); + } + Some((format!("reference-{:02}", index + 1), stats)) + }) + .collect(); + let parser_errors_recovered = read_stats + .as_ref() + .map(|stats| stats.recovered_errors) + .unwrap_or_else(|| { + notifications + .iter() + .filter(|item| { + item.notification_type + == acadrust::notification::NotificationType::Error + }) + .count() + }); + let mut diagnostics: Vec<(String, String)> = notifications + .iter() + .map(|item| { + ( + item.notification_type.to_string(), + redact_known_paths(&item.message, path, references), + ) + }) + .collect(); + if notifications.omitted_count() > 0 { + diagnostics.insert(0, ( + "truncated".to_string(), + format!( + "{} additional parser notifications were omitted after the safety limit", + notifications.omitted_count() + ), + )); + } + for (index, reference) in references.iter().enumerate() { + diagnostics.extend(reference.diagnostics.iter().map(|message| { + ( + format!("Reference {:02}", index + 1), + redact_known_paths(message, path, references), + ) + })); + } + let created_unix_seconds = unix_seconds(); + Self { + report_id: report_id(source_sha256.as_deref(), created_unix_seconds), + status: RecoveryStatus::Recovered, + tab_id: Some(tab_id), + file_name: display_name(path), + source_path: Some(path.to_path_buf()), + size_bytes, + source_sha256, + read_stats, + entities_scanned, + entities_removed, + referenced_entities_removed, + parser_errors_recovered, + diagnostics, + references_checked: references.len(), + references_loaded, + references_recovered, + references_missing, + references_failed, + references_skipped, + reference_details, + reference_stats, + timings, + total_ms, + failure_phase: None, + error: None, + save_as_required, + log_path: None, + log_error: None, + created_unix_seconds, + } + } + + pub fn failed( + path: Option, + file_name: String, + size_bytes: u64, + source_sha256: Option, + read_stats: Option, + failure_phase: String, + error: String, + total_ms: u32, + ) -> Self { + let created_unix_seconds = unix_seconds(); + Self { + report_id: report_id(source_sha256.as_deref(), created_unix_seconds), + status: RecoveryStatus::Failed, + tab_id: None, + file_name, + source_path: path, + size_bytes, + source_sha256, + parser_errors_recovered: read_stats + .as_ref() + .map(|stats| stats.recovered_errors) + .unwrap_or(0), + read_stats, + entities_scanned: 0, + entities_removed: 0, + referenced_entities_removed: 0, + diagnostics: Vec::new(), + references_checked: 0, + references_loaded: 0, + references_recovered: 0, + references_missing: 0, + references_failed: 0, + references_skipped: 0, + reference_details: Vec::new(), + reference_stats: Vec::new(), + timings: OpenTimings::default(), + total_ms, + failure_phase: Some(failure_phase), + error: Some(error), + save_as_required: false, + log_path: None, + log_error: None, + created_unix_seconds, + } + } + + pub fn removed_total(&self) -> usize { + self.entities_removed + .saturating_add(self.referenced_entities_removed) + } + + pub fn issues_found(&self) -> usize { + self.parser_errors_recovered + .saturating_add(self.references_recovered) + .saturating_add(self.references_failed) + .saturating_add(self.references_missing) + .saturating_add(self.removed_total()) + .saturating_add(usize::from(self.error.is_some())) + } + + pub fn log_text(&self) -> String { + let mut lines = Vec::with_capacity(24); + lines.push("Open CAD Studio drawing recovery report".to_string()); + lines.push(format!("Report schema: {}", REPORT_SCHEMA_VERSION)); + lines.push(format!("Report ID: {}", self.report_id)); + lines.push(format!("Application version: {}", env!("CARGO_PKG_VERSION"))); + lines.push(format!("Application revision: {}", env!("OCS_GIT_REV"))); + lines.push(format!("Build profile: {}", env!("OCS_BUILD_PROFILE"))); + lines.push(format!("Build features: {}", env!("OCS_BUILD_FEATURES"))); + lines.push(format!("Reader version: {}", acadrust::VERSION)); + lines.push(format!("Reader revision: {}", reader_revision())); + lines.push(format!("Platform: {}", diagnostic_platform())); + lines.push(format!("Created (Unix seconds): {}", self.created_unix_seconds)); + lines.push(format!( + "File: {}", + private_file_label(&self.file_name, self.source_sha256.as_deref()) + )); + lines.push(format!("Size: {} bytes", self.size_bytes)); + lines.push(format!( + "Source SHA-256: {}", + self.source_sha256.as_deref().unwrap_or("unavailable") + )); + lines.push(format!( + "Result: {}", + match self.status { + RecoveryStatus::Recovered => "opened with a recovery report", + RecoveryStatus::Failed => "could not be opened", + } + )); + lines.push(String::new()); + lines.push("Summary".to_string()); + if let Some(stats) = &self.read_stats { + lines.push(format!( + "Format: {}", + stats + .source_format + .map(|format| format.to_string()) + .unwrap_or_else(|| "unknown".to_string()) + )); + lines.push(format!("Format version: {}", stats.source_version)); + lines.push(format!("Maintenance version: {}", stats.maintenance_version)); + lines.push(format!("Recovery mode: {}", stats.recovery_mode)); + lines.push(format!("Stream completed: {}", stats.stream_completed)); + lines.push(format!("Source sections: {}", stats.source_sections)); + lines.push(format!("Observed source records: {}", stats.source_records)); + lines.push(format!( + "Successfully decoded source records: {}", + stats.decoded_source_records + )); + lines.push(format!( + "Known skipped source records: {}", + stats.skipped_source_records + )); + lines.push(format!("Output entities: {}", stats.output_entities)); + lines.push(format!("Output objects: {}", stats.output_objects)); + lines.push(format!( + "Output table records: {}", + stats.output_table_records + )); + } + lines.push(format!("Entities scanned: {}", self.entities_scanned)); + lines.push(format!("Entities removed: {}", self.entities_removed)); + lines.push(format!( + "Referenced entities removed: {}", + self.referenced_entities_removed + )); + lines.push(format!( + "Parser errors recovered: {}", + self.parser_errors_recovered + )); + lines.push(format!("References checked: {}", self.references_checked)); + lines.push(format!("References loaded: {}", self.references_loaded)); + lines.push(format!( + "References recovered: {}", + self.references_recovered + )); + lines.push(format!("References unavailable: {}", self.references_missing)); + lines.push(format!("References failed: {}", self.references_failed)); + lines.push(format!("References skipped: {}", self.references_skipped)); + lines.push(format!( + "Save as new file required: {}", + if self.save_as_required { "yes" } else { "no" } + )); + if !self.reference_details.is_empty() { + lines.push(String::new()); + lines.push("References".to_string()); + for (name, path, status, source_sha256) in &self.reference_details { + lines.push(format!( + "[{status}] {name}: {path}; SHA-256={}", + source_sha256.as_deref().unwrap_or("unavailable") + )); + } + } + if !self.reference_stats.is_empty() { + lines.push(String::new()); + lines.push("Referenced source statistics".to_string()); + for (name, stats) in &self.reference_stats { + lines.push(format!( + "{name}: format={} version={} recovery={} completed={} observed-records={} decoded={} known-skipped={}", + stats + .source_format + .map(|format| format.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + stats.source_version, + stats.recovery_mode, + stats.stream_completed, + stats.source_records, + stats.decoded_source_records, + stats.skipped_source_records, + )); + for diagnostic in stats.diagnostics.iter().take(50) { + lines.push(format!( + "{name} {}", + structured_diagnostic_line(diagnostic) + )); + } + } + } + lines.push(String::new()); + lines.push("Timing".to_string()); + lines.push(format!("Parse: {} ms", self.timings.parse_ms)); + lines.push(format!("Validation: {} ms", self.timings.purge_ms)); + lines.push(format!("References: {} ms", self.timings.xref_ms)); + lines.push(format!("Scene caches: {} ms", self.timings.caches_ms)); + lines.push(format!("Total: {} ms", self.total_ms)); + if !self.diagnostics.is_empty() { + lines.push(String::new()); + lines.push("Diagnostics".to_string()); + for (kind, message) in self.diagnostics.iter().take(200) { + lines.push(format!("[{kind}] {message}")); + } + if self.diagnostics.len() > 200 { + lines.push(format!( + "[truncated] {} additional diagnostics omitted", + self.diagnostics.len() - 200 + )); + } + } + if let Some(stats) = &self.read_stats { + if !stats.diagnostics.is_empty() { + lines.push(String::new()); + lines.push("Structured reader diagnostics".to_string()); + for diagnostic in stats.diagnostics.iter().take(100) { + lines.push(redact_source_path( + &structured_diagnostic_line(diagnostic), + self.source_path.as_deref(), + )); + } + if stats.diagnostics.len() > 100 { + lines.push(format!( + "[truncated] {} additional diagnostics omitted", + stats.diagnostics.len() - 100 + )); + } + } + } + if let Some(error) = &self.error { + lines.push(String::new()); + lines.push("Failure".to_string()); + if let Some(phase) = &self.failure_phase { + lines.push(format!("Phase: {phase}")); + } + lines.push(redact_source_path(error, self.source_path.as_deref())); + } + lines.push(String::new()); + lines.push("The source file was not modified by this operation.".to_string()); + if self.save_as_required { + lines.push( + "Save the repaired drawing as a new file before continuing work.".to_string(), + ); + } + lines.push(String::new()); + lines.join("\n") + } + + pub fn suggested_download_name(&self) -> String { + format!("drawing_recovery_{}.log", safe_stem(&self.report_id)) + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn persist(&mut self) { + match write_report(self) { + Ok(path) => self.log_path = Some(path), + Err(error) => self.log_error = Some(error), + } + } + + #[cfg(target_arch = "wasm32")] + pub fn persist(&mut self) {} +} + +fn display_name(path: &Path) -> String { + path.file_name() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn sha256_file(path: &Path) -> Result { + use std::io::Read; + + let mut file = std::fs::File::open(path).map_err(|error| error.to_string())?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 128 * 1024]; + loop { + let count = file.read(&mut buffer).map_err(|error| error.to_string())?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + Ok(hex_digest(hasher.finalize())) +} + +#[cfg(target_arch = "wasm32")] +pub fn sha256_file(_path: &Path) -> Result { + Err("filesystem hashing is unavailable on this target".to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn hex_digest(bytes: impl AsRef<[u8]>) -> String { + let bytes = bytes.as_ref(); + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write; + let _ = write!(output, "{byte:02x}"); + } + output +} + +fn report_id(source_sha256: Option<&str>, created_unix_seconds: u64) -> String { + let fingerprint = source_sha256 + .and_then(|value| value.get(..12)) + .unwrap_or("nohash"); + format!("R2-{created_unix_seconds}-{fingerprint}") +} + +fn private_file_label(file_name: &str, source_sha256: Option<&str>) -> String { + let extension = Path::new(file_name) + .extension() + .and_then(|value| value.to_str()) + .filter(|value| value.chars().all(|ch| ch.is_ascii_alphanumeric())) + .map(|value| format!(".{value}")) + .unwrap_or_default(); + let fingerprint = source_sha256 + .and_then(|value| value.get(..12)) + .unwrap_or("redacted"); + format!("drawing-{fingerprint}{extension}") +} + +fn private_path_label(value: &str) -> String { + let normalized = value.replace('\\', "/"); + let extension = Path::new(&normalized) + .extension() + .and_then(|extension| extension.to_str()) + .filter(|extension| extension.chars().all(|ch| ch.is_ascii_alphanumeric())) + .map(|extension| format!(".{extension}")) + .unwrap_or_default(); + format!("reference-redacted{extension}") +} + +fn redact_known_paths(message: &str, source: &Path, references: &[XrefInfo]) -> String { + let mut redacted = redact_source_path(message, Some(source)); + for reference in references { + let normalized = reference.path.replace('\\', "/"); + let basename = Path::new(&normalized) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + for candidate in [&reference.path, &normalized, basename] { + if !candidate.is_empty() { + redacted = redacted.replace(candidate, ""); + } + } + if !reference.name.is_empty() { + redacted = redacted.replace(&reference.name, ""); + } + } + redacted +} + +fn redact_source_path(message: &str, source: Option<&Path>) -> String { + let Some(source) = source else { + return message.to_string(); + }; + let mut redacted = message.replace(&source.to_string_lossy().to_string(), ""); + if let Some(file_name) = source.file_name().and_then(|value| value.to_str()) { + redacted = redacted.replace(file_name, ""); + } + redacted +} + +fn structured_diagnostic_line(diagnostic: &acadrust::ReadDiagnostic) -> String { + let section = diagnostic.section.as_deref().unwrap_or("-"); + let offset = diagnostic + .source_offset + .map(|value| format!("0x{value:X}")) + .unwrap_or_else(|| "-".to_string()); + let offset_basis = diagnostic.source_offset_basis.as_deref().unwrap_or("-"); + let line = diagnostic + .source_line + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()); + let handle = diagnostic + .record_handle + .map(|value| format!("0x{value:X}")) + .unwrap_or_else(|| "-".to_string()); + let record_type = diagnostic + .record_type + .as_deref() + .unwrap_or("-"); + format!( + "[code={} stage={} section={} offset={} offset-basis={} line={} handle={} type={}] {}", + diagnostic.code, + diagnostic.stage, + section, + offset, + offset_basis, + line, + handle, + record_type, + diagnostic.message + ) +} + +fn reader_revision() -> String { + include_str!("../../Cargo.toml") + .lines() + .find(|line| line.trim_start().starts_with("acadrust = { git =")) + .and_then(|line| line.split("rev = \"").nth(1)) + .and_then(|value| value.split('"').next()) + .filter(|value| !value.is_empty()) + .unwrap_or("unknown") + .to_string() +} + +fn diagnostic_platform() -> String { + format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH) +} + +fn unix_seconds() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn safe_stem(value: &str) -> String { + let filtered: String = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + ch + } else { + '_' + } + }) + .take(80) + .collect(); + if filtered.is_empty() { + "drawing".to_string() + } else { + filtered + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_report(report: &RecoveryReport) -> Result { + let file_name = report.suggested_download_name(); + let body = report.log_text(); + let mut errors = Vec::new(); + + if let Some(directory) = crate::config::config_dir().map(|path| path.join("recovery_logs")) { + if let Err(error) = std::fs::create_dir_all(&directory) { + errors.push(format!("Could not create private recovery-log directory: {error}")); + } else { + match write_unique(&directory, &file_name, &body) { + Ok(path) => return Ok(path), + Err(error) => errors.push(error), + } + } + } + + Err(if errors.is_empty() { + "No private recovery-log directory was available".to_string() + } else { + errors.join("; ") + }) +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_unique(directory: &Path, file_name: &str, body: &str) -> Result { + use std::io::Write; + + let stem = Path::new(file_name) + .file_stem() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "drawing_recovery".to_string()); + for suffix in 0..100u8 { + let candidate = if suffix == 0 { + directory.join(file_name) + } else { + directory.join(format!("{stem}_{suffix}.log")) + }; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&candidate) { + Ok(mut file) => { + file.write_all(body.as_bytes()) + .map_err(|error| format!("Could not write recovery log: {error}"))?; + return Ok(candidate); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(format!("Could not create recovery log: {error}")), + } + } + Err("Could not allocate a unique recovery-log name".to_string()) +} diff --git a/src/io/web_recent.rs b/src/io/web_recent.rs index 5d4f1cb5..a91722f2 100644 --- a/src/io/web_recent.rs +++ b/src/io/web_recent.rs @@ -7,6 +7,18 @@ use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; +struct OpenStoreState { + latest_id: u64, + latest_bytes: std::sync::Arc<[u8]>, + active_writers: usize, +} + +thread_local! { + static OPEN_STORES: std::cell::RefCell< + std::collections::HashMap, + > = std::cell::RefCell::new(std::collections::HashMap::new()); +} + const RECENT_DIRECTORY: &str = "opencadstudio-recent"; const THUMBNAIL_MAGIC: &[u8; 4] = b"OCST"; const THUMBNAIL_MAX_DIM: u32 = 256; @@ -35,6 +47,70 @@ pub async fn store(name: &str, bytes: &[u8]) -> Result<(), String> { Ok(()) } +pub async fn store_open( + name: &str, + bytes: std::sync::Arc<[u8]>, + open_id: u64, +) -> Result<(), String> { + let key = name.to_string(); + let (mut pending_id, mut pending_bytes) = OPEN_STORES.with(|stores| { + let mut stores = stores.borrow_mut(); + let state = stores.entry(key.clone()).or_insert_with(|| OpenStoreState { + latest_id: open_id, + latest_bytes: std::sync::Arc::clone(&bytes), + active_writers: 0, + }); + state.active_writers = state.active_writers.saturating_add(1); + if open_id > state.latest_id { + state.latest_id = open_id; + state.latest_bytes = bytes; + } + (state.latest_id, std::sync::Arc::clone(&state.latest_bytes)) + }); + loop { + if let Err(error) = store(name, &pending_bytes).await { + OPEN_STORES.with(|stores| { + let mut stores = stores.borrow_mut(); + let remove = if let Some(state) = stores.get_mut(&key) { + state.active_writers = state.active_writers.saturating_sub(1); + state.active_writers == 0 + } else { + false + }; + if remove { + stores.remove(&key); + } + }); + return Err(error); + } + + let latest = OPEN_STORES.with(|stores| { + let mut stores = stores.borrow_mut(); + let Some(state) = stores.get_mut(&key) else { + return None; + }; + if state.latest_id != pending_id { + return Some(( + state.latest_id, + std::sync::Arc::clone(&state.latest_bytes), + )); + } + state.active_writers = state.active_writers.saturating_sub(1); + if state.active_writers == 0 { + stores.remove(&key); + } + None + }); + match latest { + Some((latest_id, latest_bytes)) => { + pending_id = latest_id; + pending_bytes = latest_bytes; + } + None => return Ok(()), + } + } +} + async fn write_entry( directory: &web_sys::FileSystemDirectoryHandle, key: &str, diff --git a/src/io/web_worker.rs b/src/io/web_worker.rs index 3bdb97b3..8df85005 100644 --- a/src/io/web_worker.rs +++ b/src/io/web_worker.rs @@ -1,18 +1,29 @@ use std::cell::RefCell; use std::rc::Rc; -use acadrust::CadDocument; use js_sys::{Array, Object, Reflect, Uint8Array}; use wasm_bindgen::closure::Closure; use wasm_bindgen::{JsCast, JsValue}; use web_sys::{ErrorEvent, MessageEvent, Worker, WorkerOptions, WorkerType}; -pub(super) async fn parse_document(name: &str, bytes: &[u8]) -> Result { +const HASH_MARKER: &str = "\nreport-source-sha256:"; +const PROTOCOL_VERSION: u16 = 3; +const WORKER_URL: &str = "ocs-parse-worker.js?v=3"; + +pub(super) async fn parse_document( + name: &str, + bytes: &[u8], + recovery_mode: bool, + initial_error: &str, +) -> Result<(acadrust::ReadOutcome, Option), super::OpenLoadError> { let options = WorkerOptions::new(); options.set_type(WorkerType::Module); - let worker = Worker::new_with_options("ocs-parse-worker.js", &options).map_err(js_error)?; + let worker = Worker::new_with_options(WORKER_URL, &options) + .map_err(|error| super::OpenLoadError::from(js_error(error)))?; - let (sender, receiver) = iced::futures::channel::oneshot::channel(); + let (sender, receiver) = iced::futures::channel::oneshot::channel::< + Result<(acadrust::ReadOutcome, Option), super::OpenLoadError>, + >(); let sender = Rc::new(RefCell::new(Some(sender))); let message_sender = sender.clone(); let on_message = Closure::::new(move |event: MessageEvent| { @@ -23,16 +34,41 @@ pub(super) async fn parse_document(name: &str, bytes: &[u8]) -> Result), + >, + Option, + bool, + ) = bincode::deserialize(&bytes) + .map_err(|error| super::OpenLoadError::from(error.to_string()))?; + if payload.0 != PROTOCOL_VERSION { + return Err(super::OpenLoadError::from(format!( + "parser worker protocol mismatch: expected {}, received {}", + PROTOCOL_VERSION, payload.0 + ))); + } + match payload.1 { + Ok(outcome) => Ok((outcome, payload.2)), + Err((message, read_stats)) => Err(super::OpenLoadError { + message, + source_sha256: payload.2, + read_stats, + recovery_available: payload.3, + }), + } }) } else { - Err(Reflect::get(&data, &JsValue::from_str("error")) + let message = Reflect::get(&data, &JsValue::from_str("error")) .ok() .and_then(|value| value.as_string()) - .unwrap_or_else(|| "CAD parser worker failed".to_string())) + .unwrap_or_else(|| "CAD parser worker failed".to_string()); + Err(decode_worker_error(message)) }; if let Some(sender) = message_sender.borrow_mut().take() { let _ = sender.send(result); @@ -43,7 +79,7 @@ pub(super) async fn parse_document(name: &str, bytes: &[u8]) -> Result::new(move |event: ErrorEvent| { if let Some(sender) = error_sender.borrow_mut().take() { - let _ = sender.send(Err(event.message())); + let _ = sender.send(Err(super::OpenLoadError::from(event.message()))); } }); worker.set_onerror(Some(on_error.as_ref().unchecked_ref())); @@ -54,22 +90,102 @@ pub(super) async fn parse_document(name: &str, bytes: &[u8]) -> Result Result { + let options = WorkerOptions::new(); + options.set_type(WorkerType::Module); + let worker = Worker::new_with_options(WORKER_URL, &options) + .map_err(|error| super::OpenLoadError::from(js_error(error)))?; + + let (sender, receiver) = iced::futures::channel::oneshot::channel::< + Result, + >(); + let sender = Rc::new(RefCell::new(Some(sender))); + let message_sender = sender.clone(); + let on_message = Closure::::new(move |event: MessageEvent| { + let data = event.data(); + let result = Reflect::get(&data, &JsValue::from_str("digest")) + .map_err(|error| super::OpenLoadError::from(js_error(error))) + .and_then(|value| { + value + .as_string() + .ok_or_else(|| super::OpenLoadError::from("hash worker returned no digest")) + }); + if let Some(sender) = message_sender.borrow_mut().take() { + let _ = sender.send(result); + } + }); + worker.set_onmessage(Some(on_message.as_ref().unchecked_ref())); + + let error_sender = sender; + let on_error = Closure::::new(move |event: ErrorEvent| { + if let Some(sender) = error_sender.borrow_mut().take() { + let _ = sender.send(Err(super::OpenLoadError::from(event.message()))); + } + }); + worker.set_onerror(Some(on_error.as_ref().unchecked_ref())); + + let payload = Object::new(); + Reflect::set( + &payload, + &JsValue::from_str("action"), + &JsValue::from_str("hash"), + ) + .map_err(|error| super::OpenLoadError::from(js_error(error)))?; + let input = Uint8Array::from(bytes); + Reflect::set(&payload, &JsValue::from_str("bytes"), &input.buffer()) + .map_err(|error| super::OpenLoadError::from(js_error(error)))?; + let transfer = Array::new(); + transfer.push(&input.buffer()); + worker + .post_message_with_transfer(&payload, &transfer) + .map_err(|error| super::OpenLoadError::from(js_error(error)))?; + + let result = receiver + .await + .map_err(|_| super::OpenLoadError::from("hash worker closed without a result"))?; + worker.terminate(); + result +} + +fn decode_worker_error(message: String) -> super::OpenLoadError { + let Some((message, digest)) = message.rsplit_once(HASH_MARKER) else { + return super::OpenLoadError::from(message); + }; + let digest = digest.trim_start().get(..64).unwrap_or_default(); + let source_sha256 = (digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then(|| digest.to_ascii_lowercase()); + super::OpenLoadError::new(message.to_string(), source_sha256) +} + fn js_error(value: JsValue) -> String { value .as_string() diff --git a/src/io/xref.rs b/src/io/xref.rs index f75bed4f..2aee489d 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -8,11 +8,20 @@ use acadrust::{CadDocument, EntityType}; use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use std::path::{Path, PathBuf}; +#[cfg(not(target_arch = "wasm32"))] +type SourceFingerprint = crate::io::edit_lock::FileFingerprint; +#[cfg(target_arch = "wasm32")] +type SourceFingerprint = (); + /// Status of an external reference block. #[derive(Debug, Clone, PartialEq, Eq)] pub enum XrefStatus { /// File was found and loaded successfully. Loaded, + /// File was loaded with recoverable parser errors. + Recovered, + /// File was found but could not produce usable drawing data. + Failed, /// File path is set but the file could not be found or read. NotFound, /// XRef is marked Unloaded in the host DWG — we honor that and @@ -29,6 +38,10 @@ pub struct XrefInfo { /// Resolved file path (or raw path if not found). pub path: String, pub status: XrefStatus, + /// Reader diagnostics retained for the recovery report. + pub diagnostics: Vec, + pub read_stats: Option, + pub source_sha256: Option, } /// Scan `doc` for XREF block-records, resolve their paths relative to @@ -78,11 +91,25 @@ pub fn resolve_xrefs_with_progress( .map(|_| std::sync::atomic::AtomicU16::new(0)) .collect(), ); - let parsed: Vec<(String, String, Handle, Option, Option)> = xref_entries + let parsed: Vec<( + String, + String, + Handle, + Option, + Option>, + Option, + Option, + )> = xref_entries .into_par_iter() .enumerate() .map(|(xref_index, (block_name, raw_path, br_handle))| { let resolved = resolve_path(&raw_path, base_dir); + #[cfg(not(target_arch = "wasm32"))] + let initial_fingerprint = resolved.as_ref().and_then(|path| { + crate::io::edit_lock::FileFingerprint::capture(path).ok() + }); + #[cfg(target_arch = "wasm32")] + let initial_fingerprint = None; let units = std::sync::Arc::clone(&parse_units); let nested_progress = progress.as_ref().map(|progress| { let progress = std::sync::Arc::clone(progress); @@ -98,7 +125,31 @@ pub fn resolve_xrefs_with_progress( }); callback }); - let xref_doc = resolved.as_ref().and_then(|p| super::load_file_with_progress(p, nested_progress).ok()); + let xref_outcome = resolved + .as_ref() + .map(|path| super::load_file_with_progress(path, nested_progress)); + let source_sha256 = resolved.as_ref().and_then(|path| { + let needs_fingerprint = match &xref_outcome { + Some(Ok(outcome)) => { + outcome.stats.recovered() + || outcome.stats.skipped_source_records > 0 + || !outcome.stats.stream_completed + } + Some(Err(_)) => true, + None => false, + }; + if !needs_fingerprint { + return None; + } + #[cfg(not(target_arch = "wasm32"))] + { + super::stable_sha256_file(path, initial_fingerprint.as_ref()) + } + #[cfg(target_arch = "wasm32")] + { + crate::io::recovery::sha256_file(path).ok() + } + }); parse_units[xref_index].store(1000, std::sync::atomic::Ordering::Relaxed); if let Some(progress) = &progress { let completed = parse_units @@ -107,7 +158,15 @@ pub fn resolve_xrefs_with_progress( .sum(); progress(completed, total_units); } - (block_name, raw_path, br_handle, resolved, xref_doc) + ( + block_name, + raw_path, + br_handle, + resolved, + xref_outcome, + source_sha256, + initial_fingerprint, + ) }) .collect(); @@ -115,15 +174,68 @@ pub fn resolve_xrefs_with_progress( // block order (par_iter preserves it), so handle allocation is deterministic. let mut result = Vec::with_capacity(parsed.len()); let mut dropped = 0usize; - for (merge_index, (block_name, raw_path, br_handle, resolved, xref_doc)) in parsed.into_iter().enumerate() + #[allow(unused_mut, unused_variables)] + for ( + merge_index, + ( + block_name, + raw_path, + br_handle, + resolved, + xref_outcome, + mut source_sha256, + initial_fingerprint, + ), + ) in parsed.into_iter().enumerate() { - let status = if let Some(xref_doc) = xref_doc { - ensure_block_entities(doc, &block_name); - dropped += merge_xref_into_block(doc, &block_name, br_handle, xref_doc); - XrefStatus::Loaded - } else { - // Path unresolved or the file failed to parse — both are NotFound. - XrefStatus::NotFound + let (status, diagnostics, read_stats) = match xref_outcome { + Some(Ok(mut outcome)) => { + let recovered = outcome.stats.recovered() + || outcome.stats.skipped_source_records > 0 + || !outcome.stats.stream_completed + || outcome.document.notifications.iter().any(|item| { + item.notification_type == acadrust::notification::NotificationType::Error + }); + let mut diagnostics: Vec = outcome + .document + .notifications + .iter() + .map(ToString::to_string) + .collect(); + diagnostics.extend( + outcome + .stats + .diagnostics + .iter() + .map(|diagnostic| diagnostic.message.clone()), + ); + let invalid = super::purge_corrupt_entities(&mut outcome.document); + if invalid > 0 { + diagnostics.push(format!( + "normal read found {invalid} structurally invalid reference records" + )); + #[cfg(not(target_arch = "wasm32"))] + if source_sha256.is_none() { + source_sha256 = resolved.as_ref().and_then(|path| { + super::stable_sha256_file(path, initial_fingerprint.as_ref()) + }); + } + } + if recovered || invalid > 0 { + (XrefStatus::Failed, diagnostics, Some(outcome.stats)) + } else { + ensure_block_entities(doc, &block_name); + dropped += merge_xref_into_block( + doc, + &block_name, + br_handle, + outcome.document, + ); + (XrefStatus::Loaded, diagnostics, Some(outcome.stats)) + } + } + Some(Err(error)) => (XrefStatus::Failed, vec![error], None), + None => (XrefStatus::NotFound, Vec::new(), None), }; result.push(XrefInfo { @@ -132,6 +244,9 @@ pub fn resolve_xrefs_with_progress( .map(|p| p.to_string_lossy().into_owned()) .unwrap_or(raw_path), status, + diagnostics, + read_stats, + source_sha256, }); if let Some(progress) = &progress { progress( diff --git a/src/scene/mod.rs b/src/scene/mod.rs index a4d16b11..cb7a2d44 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -534,6 +534,8 @@ pub(crate) fn valid_block_name(name: &str) -> bool { /// Produced in the file-load background task so the UI thread only assigns. #[derive(Debug, Clone)] pub struct DerivedCaches { + pub read_stats: Option, + pub source_sha256: Option, pub local_extent_max: f32, pub local_center: [f64; 2], pub hatches: HashMap, @@ -825,6 +827,8 @@ fn build_derived_caches_impl( } DerivedCaches { + read_stats: None, + source_sha256: None, local_extent_max, local_center, hatches, diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index f4d90a04..0355456f 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -7,6 +7,7 @@ pub mod shortcuts; pub mod layers; pub mod update_notice; pub mod open_progress; +pub mod recovery; pub mod options; pub mod attribute_editor; pub mod alias_editor; diff --git a/src/ui/window/recovery.rs b/src/ui/window/recovery.rs new file mode 100644 index 00000000..a6d33cd6 --- /dev/null +++ b/src/ui/window/recovery.rs @@ -0,0 +1,224 @@ +use crate::app::Message; +use crate::io::recovery::{RecoveryReport, RecoveryStatus}; +use iced::widget::{button, column, container, row, scrollable, text, Space}; +use iced::{Background, Border, Element, Fill, Theme}; + +fn muted_style(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.palette().background.base.text.scale_alpha(0.68)), + } +} + +fn status_style( + status: RecoveryStatus, +) -> impl Fn(&Theme) -> iced::widget::text::Style + Copy { + move |theme: &Theme| iced::widget::text::Style { + color: Some(match status { + RecoveryStatus::Recovered => theme.palette().warning.base.color, + RecoveryStatus::Failed => theme.palette().danger.base.color, + }), + } +} + +fn metric<'a>(label: String, value: String) -> Element<'a, Message> { + container( + column![ + text(label).size(10).style(muted_style), + text(value).size(18), + ] + .spacing(3), + ) + .padding([10, 12]) + .width(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 5.0.into(), + }, + ..Default::default() + }) + .into() +} + +pub fn view_window<'a>( + report: &'a RecoveryReport, + allow_save_copy: bool, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let recovered = report.status == RecoveryStatus::Recovered; + let heading = if recovered { + crate::tr!("recovery-opened-with-repairs") + } else { + crate::tr!("recovery-open-failed") + }; + let description = if recovered { + crate::tr!("recovery-repaired-description") + } else { + crate::tr!("recovery-failed-description") + }; + let status = report.status; + + let metrics = row![ + metric( + crate::tr!("recovery-entities-checked"), + report.entities_scanned.to_string(), + ), + metric( + crate::tr!("recovery-issues-found"), + report.issues_found().to_string(), + ), + metric( + crate::tr!("recovery-entities-removed"), + report.removed_total().to_string(), + ), + metric( + crate::tr!("recovery-references-checked"), + report.references_checked.to_string(), + ), + ] + .spacing(8) + .width(Fill); + + let mut details = column![].spacing(6); + if report.referenced_entities_removed > 0 { + details = details.push( + text(format!( + "{}: {}", + crate::tr!("recovery-referenced-entities-removed"), + report.referenced_entities_removed + )) + .size(11), + ); + } + if report.references_missing > 0 || report.references_failed > 0 { + details = details.push( + text(format!( + "{}: {}", + crate::tr!("recovery-references-unavailable"), + report + .references_missing + .saturating_add(report.references_failed) + )) + .size(11), + ); + } + if let Some(error) = &report.error { + details = details.push(text(error).size(11)); + } + for (kind, message) in report.diagnostics.iter().take(100) { + details = details.push(text(format!("[{kind}] {message}")).size(10)); + } + if let Some(path) = &report.log_path { + details = details.push( + text(format!("{}: {}", crate::tr!("recovery-log-path"), path.display())) + .size(10) + .style(muted_style), + ); + } else if let Some(error) = &report.log_error { + details = details.push( + text(format!( + "{}: {error}", + crate::tr!("recovery-log-write-failed") + )) + .size(10) + .style(status_style(RecoveryStatus::Failed)), + ); + } else if cfg!(target_arch = "wasm32") { + details = details.push( + text(crate::tr!("recovery-log-download-ready")) + .size(10) + .style(muted_style), + ); + } + + let detail_panel = container(scrollable(details).height(Fill)) + .padding([10, 12]) + .width(Fill) + .height(Fill) + .style(container::bordered_box); + + let mut actions = row![Space::new().width(Fill)].spacing(8); + if recovered && report.save_as_required && allow_save_copy { + actions = actions.push( + button(text(crate::tr!("recovery-save-copy")).size(12)) + .on_press(Message::RecoverySaveAs) + .style(button::primary) + .padding([6, 14]), + ); + } + if report.log_path.is_some() || cfg!(target_arch = "wasm32") { + actions = actions.push( + button(text(crate::tr!("recovery-show-log")).size(12)) + .on_press(Message::RecoveryShowLog) + .style(button::secondary) + .padding([6, 14]), + ); + } + actions = actions.push( + button(text(crate::tr!("action-close")).size(12)) + .on_press(Message::RecoveryClose) + .style(button::secondary) + .padding([6, 14]), + ); + + container( + column![ + text(heading).size(20).style(status_style(status)), + text(&report.file_name).size(13), + text(description).size(11).style(muted_style), + Space::new().height(4), + metrics, + detail_panel, + actions, + ] + .spacing(10) + .width(sizing.width) + .height(sizing.height), + ) + .padding([12, 16]) + .width(sizing.width) + .height(sizing.height) + .into() +} + +pub fn view_prompt<'a>( + file_name: &'a str, + error: &'a str, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let actions = row![ + Space::new().width(Fill), + button(text(crate::tr!("recovery-decline")).size(12)) + .on_press(Message::RecoveryDecline) + .style(button::secondary) + .padding([6, 14]), + button(text(crate::tr!("recovery-attempt")).size(12)) + .on_press(Message::RecoveryAttempt) + .style(button::primary) + .padding([6, 14]), + ] + .spacing(8); + + container( + column![ + text(crate::tr!("recovery-prompt-heading")).size(20), + text(file_name).size(13), + text(crate::tr!("recovery-prompt-description")) + .size(11) + .style(muted_style), + container(scrollable(text(error).size(10)).height(Fill)) + .padding([10, 12]) + .width(Fill) + .height(Fill) + .style(container::bordered_box), + actions, + ] + .spacing(10) + .width(sizing.width) + .height(sizing.height), + ) + .padding([12, 16]) + .into() +} diff --git a/web/ocs-parse-worker.js b/web/ocs-parse-worker.js index ca98b1ec..5eb06381 100644 --- a/web/ocs-parse-worker.js +++ b/web/ocs-parse-worker.js @@ -1,14 +1,23 @@ -import init, { parse_document } from "./worker_pkg/ocs_web_worker.js"; +import init, { parse_document, sha256_document } from "./worker_pkg/ocs_web_worker.js?v=3"; -const ready = init(); +const ready = init( + new URL("./worker_pkg/ocs_web_worker_bg.wasm?v=3", import.meta.url), +); self.onmessage = async ({ data }) => { let stage = "initialize worker"; try { await ready; + if (data.action === "hash") { + const digest = sha256_document(new Uint8Array(data.bytes)); + self.postMessage({ ok: true, digest }); + return; + } const encoded = parse_document( data.name, new Uint8Array(data.bytes), + data.recoveryMode === true, + data.initialError || "", (next) => { stage = next; }, @@ -21,9 +30,7 @@ self.onmessage = async ({ data }) => { } catch (error) { self.postMessage({ ok: false, - error: `${stage}: ${ - error instanceof Error ? error.stack || error.message : String(error) - }`, + error: `${stage}: ${error instanceof Error ? error.message : String(error)}`, }); } };