forked from 1b-agent/opencadstudio-upstream
The codec and kernel repositories and crates are renamed opencadcodec and opencadkernel. Inside OCS they are depended on under short keys, so code reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node graph engine). File-format and protocol spellings stay as they were: the ACIS product id `acadrust`, the ACADRUST_* switches and the plugin version keys `acadrust_version` / `acadrust_source`. A button left of MCP on the command line opens a translucent node graph over the viewport, with a palette of object, input, math, logic, list, text and geometry nodes added by click or drag. An object node owns drawing entities and its rows are the Properties panel's own rows for them: the single-object page is extracted from refresh_properties as entity_property_sections, values use the control protocol's property_json, and writes go through set_property_value with a property target override, so the panel, MCP and the graph share one read/write path. A list reaching an object row makes one entity per element. Each graph action is one undo step; the canvas pans and zooms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
721 lines
25 KiB
Rust
721 lines
25 KiB
Rust
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<codec::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, codec::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<codec::ReadStats>,
|
|
entities_scanned: usize,
|
|
entities_removed: usize,
|
|
referenced_entities_removed: usize,
|
|
references: &[XrefInfo],
|
|
notifications: &codec::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
|
|
== codec::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<codec::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!("OCS_APP_VERSION")));
|
|
lines.push(format!("Application build: {}", env!("OCS_FULL_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: {}", codec::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!("Geometry prep: {} ms", self.timings.finalize_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: &codec::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("opencadcodec = { 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)
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn unix_seconds() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|duration| duration.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn unix_seconds() -> u64 {
|
|
(js_sys::Date::now() / 1000.0) as u64
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{report_id, RecoveryReport, RecoveryStatus};
|
|
|
|
#[test]
|
|
fn report_id_includes_timestamp_and_source_fingerprint() {
|
|
assert_eq!(
|
|
report_id(
|
|
Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"),
|
|
1_777_777_777,
|
|
),
|
|
"R2-1777777777-0123456789ab"
|
|
);
|
|
assert_eq!(report_id(None, 1_777_777_777), "R2-1777777777-nohash");
|
|
}
|
|
|
|
#[test]
|
|
fn failed_report_uses_current_timestamp_in_report_id() {
|
|
let report = RecoveryReport::failed(
|
|
None,
|
|
"invalid.dwg".to_string(),
|
|
42,
|
|
None,
|
|
None,
|
|
"parse".to_string(),
|
|
"invalid drawing".to_string(),
|
|
7,
|
|
);
|
|
|
|
assert_eq!(report.status, RecoveryStatus::Failed);
|
|
assert!(report.created_unix_seconds > 0);
|
|
assert_eq!(
|
|
report.report_id,
|
|
report_id(report.source_sha256.as_deref(), report.created_unix_seconds)
|
|
);
|
|
}
|
|
}
|