use super::{
foreign::{ForeignFormat, convert_foreign_design, converted_asc_file_name},
measure::{apply_measured_metadata, merge_reimport_metadata},
scan::{collect_import_candidates, find_design_file_beside, find_native_sidecar},
};
use indicatrix_vault::{db::sqlite::Database, local, model::file::AttachedFile};
use std::{
collections::HashSet,
path::Path,
sync::{Arc, Mutex},
};
use tracing::warn;
pub fn catch_file_panic<T>(f: impl FnOnce() -> T + std::panic::UnwindSafe) -> Result<T, String> {
std::panic::catch_unwind(f).map_err(|payload| {
payload
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".to_string())
})
}
pub(super) struct ImportOutcome {
pub(super) summary: String,
pub(super) imported_ids: Vec<i64>,
pub(super) had_failures: bool,
pub(super) had_collision: bool,
pub(super) had_notes: bool,
}
fn save_imported_design(
db: &Arc<Mutex<Database>>,
url: &str,
seen_before_in_batch: bool,
parsed: local::ImportedAsc,
file_name: &str,
) -> anyhow::Result<(i64, bool)> {
let local::ImportedAsc {
entry,
mut detail,
derived_from_entry_id,
extras,
} = parsed;
let (id, is_collision) = {
let db = db.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let is_collision = seen_before_in_batch || db.has_detail_for_entry_url(url)?;
if is_collision
&& let Some(existing_id) = db.diagram_entry_id_for_url(&entry.url)?
&& let Ok(Some(existing)) = db.get_diagram_full(existing_id)
{
merge_reimport_metadata(&mut detail, &existing);
}
let id = db.save_design(&entry, &detail, local::LOCAL_SOURCE_ID)?;
if let Err(e) = local::apply_imported_extras(&db, id, &extras) {
warn!("Import: could not restore the tags/marks of '{file_name}' on entry #{id}: {e}");
}
if is_collision {
invalidate_stale_caches(&db, id, file_name);
} else if let Some(source_id) = derived_from_entry_id {
record_provenance(&db, id, source_id, file_name);
}
drop(db);
(id, is_collision)
};
Ok((id, is_collision))
}
fn invalidate_stale_caches(db: &Database, id: i64, file_name: &str) {
if let Err(e) = db.delete_preview_images(id) {
warn!(
"Import: failed to invalidate stale preview cache for entry #{id} \
('{file_name}'): {e}"
);
}
if let Err(e) = db.delete_tilt_curves(id) {
warn!(
"Import: failed to invalidate stale tilt-curve cache for entry #{id} \
('{file_name}'): {e}"
);
}
if let Err(e) = db.delete_solid_extents(id) {
warn!(
"Import: failed to invalidate stale solid-extents cache for entry #{id} \
('{file_name}'): {e}"
);
}
}
fn record_provenance(db: &Database, id: i64, source_id: i64, file_name: &str) {
match db.get_diagram_full(source_id) {
Ok(Some(_)) => {
if let Err(e) = db.set_derived_from_entry_id(id, Some(source_id)) {
warn!(
"Import: failed to record entry #{id} ('{file_name}') as \
derived from #{source_id}: {e}"
);
}
}
Ok(None) => {
}
Err(e) => warn!(
"Import: could not verify source entry #{source_id} for '{file_name}' \
before recording provenance: {e}"
),
}
}
fn parse_one_import(
file_name: &str,
bytes: &[u8],
sidecar: Option<&(String, Vec<u8>)>,
) -> Result<local::ImportedAsc, String> {
parse_guarded(file_name, || {
local::import_asc_bytes(
file_name,
bytes,
sidecar.map(|(name, bytes)| (name.as_str(), bytes.as_slice())),
)
})
}
fn parse_guarded(
file_name: &str,
import: impl FnOnce() -> Result<local::ImportedAsc, String>,
) -> Result<local::ImportedAsc, String> {
let parse_result = catch_file_panic(std::panic::AssertUnwindSafe(|| {
import().map(|mut parsed| {
apply_measured_metadata(&mut parsed.detail);
parsed
})
}));
match parse_result {
Ok(Ok(parsed)) => Ok(parsed),
Ok(Err(e)) => Err(format!("{file_name} (parse error: {e})")),
Err(panic_msg) => {
warn!("Import panicked while processing '{file_name}': {panic_msg}");
Err(format!("{file_name} (internal error: {panic_msg})"))
}
}
}
const WARNINGS_PER_FILE: usize = 3;
fn parse_foreign_import(
file_name: &str,
format: ForeignFormat,
bytes: &[u8],
design_file: Option<(String, Vec<u8>)>,
) -> Result<(local::ImportedAsc, Vec<String>), String> {
let converted = match catch_file_panic(std::panic::AssertUnwindSafe(|| {
convert_foreign_design(format, bytes)
})) {
Ok(Ok(converted)) => converted,
Ok(Err(e)) => return Err(format!("{file_name} (parse error: {e})")),
Err(panic_msg) => {
warn!("Import panicked while converting '{file_name}': {panic_msg}");
return Err(format!("{file_name} (internal error: {panic_msg})"));
}
};
let mut parsed = parse_guarded(file_name, || {
local::import_asc(file_name, &converted.asc_text, None)
})?;
let asc_name = converted_asc_file_name(file_name);
if let Some(generated) = parsed.detail.attached_files.first_mut() {
generated.name.clone_from(&asc_name);
}
parsed.detail.attached_files.push(AttachedFile {
name: file_name.to_string(),
url: String::new(),
content: bytes.to_vec(),
});
if let Some((name, content)) = design_file {
parsed.detail.attached_files.push(AttachedFile {
name,
url: String::new(),
content,
});
}
if format == ForeignFormat::Gem {
parsed.detail.gem_file = Some(file_name.to_string());
}
if parsed.entry.title == file_name {
parsed.entry.title = asc_name.trim_end_matches(".asc").to_string();
}
Ok((parsed, converted.warnings))
}
fn warning_line(file_name: &str, warnings: &[String]) -> String {
let shown = warnings
.iter()
.take(WARNINGS_PER_FILE)
.cloned()
.collect::<Vec<_>>()
.join("; ");
let rest = warnings.len().saturating_sub(WARNINGS_PER_FILE);
if rest > 0 {
format!("{file_name}: {shown} (+{rest} more)")
} else {
format!("{file_name}: {shown}")
}
}
fn read_and_parse_candidate(
file_path: &Path,
file_name: &str,
) -> Result<(local::ImportedAsc, Vec<String>), String> {
let bytes = std::fs::read(file_path).map_err(|e| format!("{file_name} (read error: {e})"))?;
if let Some(format) = ForeignFormat::from_path(file_path) {
let design_file = find_design_file_beside(file_path);
return parse_foreign_import(file_name, format, &bytes, design_file);
}
if local::is_native_design_name(file_name) {
return parse_guarded(file_name, || local::import_native_design(file_name, &bytes))
.map(|parsed| (parsed, Vec::new()));
}
let sidecar = find_native_sidecar(file_path);
parse_one_import(file_name, &bytes, sidecar.as_ref()).map(|parsed| (parsed, Vec::new()))
}
pub(super) fn import_path(
db: &Arc<Mutex<Database>>,
path: &Path,
recurse: bool,
mut on_progress: impl FnMut(usize, usize),
) -> ImportOutcome {
let candidates = match collect_import_candidates(path, recurse) {
Ok(c) => c,
Err(message) => {
return ImportOutcome {
summary: message,
imported_ids: Vec::new(),
had_failures: true,
had_collision: false,
had_notes: false,
};
}
};
let total = candidates.len();
let mut imported_ids: Vec<i64> = Vec::new();
let mut failed: Vec<String> = Vec::new();
let mut seen_in_batch: HashSet<String> = HashSet::new();
let mut replaced: Vec<String> = Vec::new();
let mut noted: Vec<String> = Vec::new();
for (i, file_path) in candidates.into_iter().enumerate() {
let file_name = file_path.file_name().map_or_else(
|| "unknown.asc".to_string(),
|n| n.to_string_lossy().into_owned(),
);
on_progress(i + 1, total);
let url = format!("local://{file_name}");
let seen_before_in_batch = !seen_in_batch.insert(file_name.clone());
let (parsed, warnings) = match read_and_parse_candidate(&file_path, &file_name) {
Ok(result) => result,
Err(message) => {
failed.push(message);
continue;
}
};
match save_imported_design(db, &url, seen_before_in_batch, parsed, &file_name) {
Ok((id, is_collision)) => {
imported_ids.push(id);
if !warnings.is_empty() {
tracing::info!("Import: '{file_name}' converted with notes: {warnings:?}");
noted.push(warning_line(&file_name, &warnings));
}
if is_collision {
replaced.push(file_name);
}
}
Err(e) => failed.push(format!("{file_name} (save error: {e})")),
}
}
ImportOutcome {
summary: import_summary(path, imported_ids.len(), &failed, &replaced, ¬ed),
imported_ids,
had_failures: !failed.is_empty(),
had_collision: !replaced.is_empty(),
had_notes: !noted.is_empty(),
}
}
fn import_summary(
path: &Path,
imported: usize,
failed: &[String],
replaced: &[String],
noted: &[String],
) -> String {
use std::fmt::Write as _;
let mut summary = if failed.is_empty() {
format!("Imported {imported} file(s).")
} else {
warn!(
"Import from {}: {} failed: {:?}",
path.display(),
failed.len(),
failed
);
format!(
"Imported {imported} file(s); {} skipped ({}).",
failed.len(),
name_preview(failed, "; ")
)
};
if !replaced.is_empty() {
tracing::info!(
"Import from {}: {} replaced by file name: {:?}",
path.display(),
replaced.len(),
replaced
);
let _ = write!(
summary,
" {} design(s) replaced an existing entry with the same file name ({}) -- \
filename-only matching, not a content comparison.",
replaced.len(),
name_preview(replaced, ", ")
);
}
if !noted.is_empty() {
let _ = write!(
summary,
" Converted with notes: {}.",
name_preview(noted, " | ")
);
}
summary
}
const SUMMARY_NAME_PREVIEW: usize = 8;
fn name_preview(names: &[String], separator: &str) -> String {
let shown = names
.iter()
.take(SUMMARY_NAME_PREVIEW)
.cloned()
.collect::<Vec<_>>();
let mut text = shown.join(separator);
let rest = names.len().saturating_sub(SUMMARY_NAME_PREVIEW);
if rest > 0 {
use std::fmt::Write as _;
let _ = write!(text, "{separator}and {rest} more");
}
text
}
#[cfg(test)]
mod summary_tests {
use super::{SUMMARY_NAME_PREVIEW, name_preview};
#[test]
fn a_short_list_is_joined_in_full() {
let names = vec!["a.asc".to_string(), "b.asc".to_string()];
assert_eq!(name_preview(&names, ", "), "a.asc, b.asc");
}
#[test]
fn a_long_list_is_capped_with_a_count_of_the_rest() {
let names: Vec<String> = (0..300).map(|i| format!("f{i}.asc")).collect();
let text = name_preview(&names, ", ");
assert!(text.starts_with("f0.asc, f1.asc"));
assert!(text.ends_with(&format!("and {} more", 300 - SUMMARY_NAME_PREVIEW)));
assert_eq!(text.matches(".asc").count(), SUMMARY_NAME_PREVIEW);
}
}