feat(io): add user-approved recovery
This commit is contained in:
parent
25515fa33c
commit
9e90e50662
25 changed files with 2723 additions and 198 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
47
build.rs
47
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<String> = 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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<Uint8Array, JsValue> {
|
||||
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<acadrust::ReadOutcome, (String, Option<acadrust::ReadStats>)>,
|
||||
source_sha256: Option<String>,
|
||||
recoverable_parse_error: bool,
|
||||
bytes: &[u8],
|
||||
) -> Result<Uint8Array, JsValue> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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 }]:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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): \"{}\"",
|
||||
|
|
|
|||
|
|
@ -121,6 +121,9 @@ pub(super) struct DocumentTab {
|
|||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) disk_fingerprint: Option<crate::io::edit_lock::FileFingerprint>,
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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<std::path::PathBuf>,
|
||||
pub size_bytes: u64,
|
||||
pub state: Arc<crate::io::OpenProgressState>,
|
||||
pub started: Instant,
|
||||
pub recovery_error: Option<String>,
|
||||
pub recovery_read_stats: Option<acadrust::ReadStats>,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub recovery_bytes: Option<std::sync::Arc<[u8]>>,
|
||||
/// 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<OpenProgress>,
|
||||
open_job_serial: u64,
|
||||
/// Last repair or failed-open report shown in the recovery modal.
|
||||
recovery_report: Option<crate::io::recovery::RecoveryReport>,
|
||||
/// 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<PathBuf, String>),
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -594,16 +594,29 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
|||
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<Message> {
|
|||
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<Message> {
|
|||
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<Message> {
|
|||
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<Message> {
|
|||
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<Message> {
|
|||
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<Message> {
|
|||
// `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<Message> {
|
|||
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<Message> {
|
|||
.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<Message> {
|
|||
}
|
||||
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<Message> {
|
|||
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<Message> {
|
|||
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<Message> {
|
|||
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<Message> {
|
|||
);
|
||||
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<Message> {
|
|||
// 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<Message> {
|
|||
/// 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<Message> {
|
||||
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<Message> {
|
|||
.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<Message> {
|
|||
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<Message> {
|
|||
/// document's source type and version, then save.
|
||||
pub(super) fn on_aec_drop_same_version(&mut self) -> Task<Message> {
|
||||
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<Message> {
|
|||
self.on_save_dialog_confirm()
|
||||
}
|
||||
|
||||
/// Where the autosave recovery copy for tab `i` lives: beside a saved
|
||||
/// drawing as `<file>.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
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
715
src/io/mod.rs
715
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<String>,
|
||||
pub read_stats: Option<acadrust::ReadStats>,
|
||||
pub recovery_available: bool,
|
||||
}
|
||||
|
||||
impl OpenLoadError {
|
||||
fn new(message: impl Into<String>, source_sha256: Option<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
source_sha256,
|
||||
read_stats: None,
|
||||
recovery_available: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn recovery_prompt(
|
||||
message: impl Into<String>,
|
||||
read_stats: Option<acadrust::ReadStats>,
|
||||
) -> 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<String> 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<OpenProgressState>,
|
||||
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<OpenProgressState>,
|
||||
_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<OpenProgressState>,
|
||||
model_bg: [f32; 4],
|
||||
initial_error: String,
|
||||
initial_stats: Option<acadrust::ReadStats>,
|
||||
) -> 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<acadrust::ReadStats>),
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct OpenAttemptFailure {
|
||||
message: String,
|
||||
read_stats: Option<acadrust::ReadStats>,
|
||||
recoverable: bool,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl From<String> 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<OpenProgressState>,
|
||||
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<String> {
|
||||
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::<String>()
|
||||
.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<Arc<[u8]>>,
|
||||
pub cache_bytes: Option<Arc<[u8]>>,
|
||||
pub record_recent: bool,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn pick_and_load_web(
|
||||
progress: Arc<OpenProgressState>,
|
||||
) -> 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<OpenProgressState>,
|
||||
) -> 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<OpenProgressState>,
|
||||
initial_error: String,
|
||||
initial_stats: Option<acadrust::ReadStats>,
|
||||
) -> 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<OpenProgressState>,
|
||||
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<OpenProgressState>,
|
||||
) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> {
|
||||
recovery_mode: bool,
|
||||
initial_error: &str,
|
||||
mut initial_stats: Option<acadrust::ReadStats>,
|
||||
) -> 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<CadDocument, String> {
|
||||
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<Arc<dyn Fn(u16) + Send + Sync>>,
|
||||
) -> Result<CadDocument, String> {
|
||||
) -> Result<acadrust::ReadOutcome, String> {
|
||||
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<Arc<dyn Fn(u16) + Send + Sync>>,
|
||||
attempt: &OpenAttempt,
|
||||
) -> Result<acadrust::ReadOutcome, OpenAttemptFailure> {
|
||||
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<String>) -> 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<acadrust::ReadStats>,
|
||||
fallback: Option<acadrust::ReadStats>,
|
||||
) -> Option<acadrust::ReadStats> {
|
||||
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<Arc<dyn Fn(u16) + Send + Sync>>,
|
||||
failsafe: bool,
|
||||
) -> Result<acadrust::ReadOutcome, ReaderFailure> {
|
||||
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<acadrust::ReadOutcome, String> {
|
||||
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<Arc<dyn Fn(u16) + Send + Sync>>,
|
||||
failsafe: bool,
|
||||
) -> Result<acadrust::ReadOutcome, ReaderFailure> {
|
||||
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<acadrust::ReadOutcome, ReaderFailure> {
|
||||
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) {
|
||||
|
|
|
|||
674
src/io/recovery.rs
Normal file
674
src/io/recovery.rs
Normal file
|
|
@ -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<u64>,
|
||||
pub file_name: String,
|
||||
pub source_path: Option<PathBuf>,
|
||||
pub size_bytes: u64,
|
||||
pub source_sha256: Option<String>,
|
||||
pub read_stats: Option<acadrust::ReadStats>,
|
||||
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<String>)>,
|
||||
pub reference_stats: Vec<(String, acadrust::ReadStats)>,
|
||||
pub timings: OpenTimings,
|
||||
pub total_ms: u32,
|
||||
pub failure_phase: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub save_as_required: bool,
|
||||
pub log_path: Option<PathBuf>,
|
||||
pub log_error: Option<String>,
|
||||
pub created_unix_seconds: u64,
|
||||
}
|
||||
|
||||
impl RecoveryReport {
|
||||
pub fn recovered(
|
||||
tab_id: u64,
|
||||
path: &Path,
|
||||
size_bytes: u64,
|
||||
source_sha256: Option<String>,
|
||||
read_stats: Option<acadrust::ReadStats>,
|
||||
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<PathBuf>,
|
||||
file_name: String,
|
||||
size_bytes: u64,
|
||||
source_sha256: Option<String>,
|
||||
read_stats: Option<acadrust::ReadStats>,
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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, "<reference-path>");
|
||||
}
|
||||
}
|
||||
if !reference.name.is_empty() {
|
||||
redacted = redacted.replace(&reference.name, "<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(), "<source-path>");
|
||||
if let Some(file_name) = source.file_name().and_then(|value| value.to_str()) {
|
||||
redacted = redacted.replace(file_name, "<source-file>");
|
||||
}
|
||||
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<PathBuf, String> {
|
||||
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<PathBuf, String> {
|
||||
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())
|
||||
}
|
||||
|
|
@ -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<String, OpenStoreState>,
|
||||
> = 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,
|
||||
|
|
|
|||
|
|
@ -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<CadDocument, String> {
|
||||
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<String>), 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<String>), super::OpenLoadError>,
|
||||
>();
|
||||
let sender = Rc::new(RefCell::new(Some(sender)));
|
||||
let message_sender = sender.clone();
|
||||
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |event: MessageEvent| {
|
||||
|
|
@ -23,16 +34,41 @@ pub(super) async fn parse_document(name: &str, bytes: &[u8]) -> Result<CadDocume
|
|||
.unwrap_or(false);
|
||||
let result = if ok {
|
||||
Reflect::get(&data, &JsValue::from_str("data"))
|
||||
.map_err(js_error)
|
||||
.map_err(|error| super::OpenLoadError::from(js_error(error)))
|
||||
.and_then(|value| {
|
||||
let bytes = Uint8Array::new(&value).to_vec();
|
||||
bincode::deserialize(&bytes).map_err(|error| error.to_string())
|
||||
let payload: (
|
||||
u16,
|
||||
Result<
|
||||
acadrust::ReadOutcome,
|
||||
(String, Option<acadrust::ReadStats>),
|
||||
>,
|
||||
Option<String>,
|
||||
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<CadDocume
|
|||
let error_sender = sender;
|
||||
let on_error = Closure::<dyn FnMut(ErrorEvent)>::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<CadDocume
|
|||
&JsValue::from_str("name"),
|
||||
&JsValue::from_str(name),
|
||||
)
|
||||
.map_err(js_error)?;
|
||||
.map_err(|error| super::OpenLoadError::from(js_error(error)))?;
|
||||
Reflect::set(
|
||||
&payload,
|
||||
&JsValue::from_str("recoveryMode"),
|
||||
&JsValue::from_bool(recovery_mode),
|
||||
)
|
||||
.map_err(|error| super::OpenLoadError::from(js_error(error)))?;
|
||||
Reflect::set(
|
||||
&payload,
|
||||
&JsValue::from_str("initialError"),
|
||||
&JsValue::from_str(initial_error),
|
||||
)
|
||||
.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(js_error)?;
|
||||
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(js_error)?;
|
||||
.map_err(|error| super::OpenLoadError::from(js_error(error)))?;
|
||||
|
||||
let result = receiver
|
||||
.await
|
||||
.map_err(|_| "CAD parser worker closed without a result".to_string())?;
|
||||
.map_err(|_| super::OpenLoadError::from("CAD parser worker closed without a result"))?;
|
||||
worker.terminate();
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) async fn sha256_document(bytes: &[u8]) -> Result<String, super::OpenLoadError> {
|
||||
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<String, super::OpenLoadError>,
|
||||
>();
|
||||
let sender = Rc::new(RefCell::new(Some(sender)));
|
||||
let message_sender = sender.clone();
|
||||
let on_message = Closure::<dyn FnMut(MessageEvent)>::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::<dyn FnMut(ErrorEvent)>::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()
|
||||
|
|
|
|||
137
src/io/xref.rs
137
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<String>,
|
||||
pub read_stats: Option<acadrust::ReadStats>,
|
||||
pub source_sha256: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf>, Option<CadDocument>)> = xref_entries
|
||||
let parsed: Vec<(
|
||||
String,
|
||||
String,
|
||||
Handle,
|
||||
Option<PathBuf>,
|
||||
Option<Result<acadrust::ReadOutcome, String>>,
|
||||
Option<String>,
|
||||
Option<SourceFingerprint>,
|
||||
)> = 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<String> = 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(
|
||||
|
|
|
|||
|
|
@ -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<acadrust::ReadStats>,
|
||||
pub source_sha256: Option<String>,
|
||||
pub local_extent_max: f32,
|
||||
pub local_center: [f64; 2],
|
||||
pub hatches: HashMap<Handle, HatchModel>,
|
||||
|
|
@ -825,6 +827,8 @@ fn build_derived_caches_impl(
|
|||
}
|
||||
|
||||
DerivedCaches {
|
||||
read_stats: None,
|
||||
source_sha256: None,
|
||||
local_extent_max,
|
||||
local_center,
|
||||
hatches,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
224
src/ui/window/recovery.rs
Normal file
224
src/ui/window/recovery.rs
Normal file
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue