#![cfg(not(target_arch = "wasm32"))]
use super::kicad_import::{
import_part, parse_step, part_from_symbol, read_symbol_library, FootprintChoice, Imported,
KicadFiles, KicadLibrary, KicadPart, ModelFile,
};
use crate::store::ModelStore;
use brep_render::engine_state::EngineState;
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
const MODEL_MEMO: usize = 32;
const LEDGER_NOTES: usize = 3;
const LEDGER_NOTE_CHARS: usize = 200;
const SHOWN_NOTES: usize = 8;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Grade {
Full,
NoModel,
ModelUnreadable,
NoFootprint,
}
impl Grade {
pub fn as_str(self) -> &'static str {
match self {
Grade::Full => "full",
Grade::NoModel => "no-model",
Grade::ModelUnreadable => "model-unreadable",
Grade::NoFootprint => "no-footprint",
}
}
pub fn from_str(word: &str) -> Option<Self> {
[Grade::Full, Grade::NoModel, Grade::ModelUnreadable, Grade::NoFootprint]
.into_iter()
.find(|grade| grade.as_str() == word)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Outcome {
Done(Grade),
Failed(String),
}
impl Outcome {
pub fn settled(&self) -> bool {
matches!(self, Outcome::Done(_))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Record {
pub key: String,
pub outcome: Outcome,
pub document: String,
pub notes: Vec<String>,
pub at: u64,
}
impl Record {
pub fn to_line(&self) -> String {
let mut value = json!({
"key": self.key,
"document": self.document,
"at": self.at,
});
match &self.outcome {
Outcome::Done(grade) => {
value["outcome"] = json!("done");
value["grade"] = json!(grade.as_str());
}
Outcome::Failed(reason) => {
value["outcome"] = json!("failed");
value["reason"] = json!(trim_note(reason));
}
}
if !self.notes.is_empty() {
value["notes"] = json!(self.notes);
}
format!("{value}\n")
}
pub fn from_line(line: &str) -> Option<Self> {
let value: Value = serde_json::from_str(line.trim()).ok()?;
let key = value.get("key")?.as_str()?.to_owned();
if key.is_empty() {
return None;
}
let outcome = match value.get("outcome").and_then(Value::as_str)? {
"done" => Outcome::Done(Grade::from_str(value.get("grade").and_then(Value::as_str)?)?),
"failed" => Outcome::Failed(
value.get("reason").and_then(Value::as_str).unwrap_or("it did not say").to_owned(),
),
_ => return None,
};
Some(Record {
key,
outcome,
document: value.get("document").and_then(Value::as_str).unwrap_or_default().to_owned(),
notes: value
.get("notes")
.and_then(Value::as_array)
.map(|list| list.iter().filter_map(Value::as_str).map(str::to_owned).collect())
.unwrap_or_default(),
at: value.get("at").and_then(Value::as_u64).unwrap_or(0),
})
}
}
fn trim_note(note: &str) -> String {
let flat = note.replace(['\n', '\r'], " ");
match flat.char_indices().nth(LEDGER_NOTE_CHARS) {
Some((at, _)) => format!("{}…", &flat[..at]),
None => flat,
}
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_secs())
.unwrap_or(0)
}
pub fn parse_ledger(text: &str) -> (BTreeMap<String, Record>, usize) {
let mut records = BTreeMap::new();
let mut dropped = 0;
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
match Record::from_line(line) {
Some(record) => {
records.insert(record.key.clone(), record);
}
None => dropped += 1,
}
}
(records, dropped)
}
fn tag_slug(tag: &str) -> String {
let safe: String = tag
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
.collect();
let trimmed = safe.trim_matches(['.', '_']).to_owned();
if trimmed.is_empty() {
"untagged".into()
} else {
trimmed
}
}
pub struct Ledger {
path: PathBuf,
records: BTreeMap<String, Record>,
dropped: usize,
file: Option<std::fs::File>,
}
impl Ledger {
pub fn path_for(cache_root: &Path, tag: &str) -> PathBuf {
cache_root.join(format!("bulk-import-{}.jsonl", tag_slug(tag)))
}
pub fn read(cache_root: &Path, tag: &str) -> Self {
let path = Ledger::path_for(cache_root, tag);
let (records, dropped) = match std::fs::read_to_string(&path) {
Ok(text) => parse_ledger(&text),
Err(_) => (BTreeMap::new(), 0),
};
Self { path, records, dropped, file: None }
}
pub fn open(cache_root: &Path, tag: &str) -> Result<Self, String> {
let mut ledger = Ledger::read(cache_root, tag);
if let Some(dir) = ledger.path.parent() {
std::fs::create_dir_all(dir).map_err(|error| format!("{}: {error}", dir.display()))?;
}
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&ledger.path)
.map_err(|error| format!("{}: {error}", ledger.path.display()))?;
ledger.file = Some(file);
Ok(ledger)
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn dropped(&self) -> usize {
self.dropped
}
pub fn get(&self, key: &str) -> Option<&Record> {
self.records.get(key)
}
pub fn settled(&self, key: &str) -> bool {
self.records.get(key).is_some_and(|record| record.outcome.settled())
}
pub fn counts(&self) -> (usize, usize) {
let settled = self.records.values().filter(|record| record.outcome.settled()).count();
(settled, self.records.len() - settled)
}
pub fn grades(&self) -> Vec<(Grade, usize)> {
[Grade::Full, Grade::NoModel, Grade::ModelUnreadable, Grade::NoFootprint]
.into_iter()
.map(|grade| {
let count = self
.records
.values()
.filter(|record| record.outcome == Outcome::Done(grade))
.count();
(grade, count)
})
.collect()
}
pub fn append(&mut self, record: Record) -> Result<(), String> {
use std::io::Write;
let line = record.to_line();
if let Some(file) = &mut self.file {
file.write_all(line.as_bytes()).map_err(|error| format!("{}: {error}", self.path.display()))?;
file.flush().map_err(|error| format!("{}: {error}", self.path.display()))?;
}
self.records.insert(record.key.clone(), record);
Ok(())
}
}
pub trait PartWriter {
fn write(&self, document: &str, json: &str) -> Result<(), String>;
}
pub struct StoreWriter<'a>(pub &'a dyn ModelStore);
impl PartWriter for StoreWriter<'_> {
fn write(&self, document: &str, json: &str) -> Result<(), String> {
self.0.write(document, json)
}
}
pub struct AppStoreWriter;
impl PartWriter for AppStoreWriter {
fn write(&self, document: &str, json: &str) -> Result<(), String> {
crate::store::default_model_store().write(document, json)
}
}
pub fn write_part(writer: &dyn PartWriter, document: &str, part: &KicadPart) -> Result<Imported, String> {
let mut state = EngineState::new();
let imported = import_part(&mut state, part)?;
writer.write(document, &state.history_request_json())?;
Ok(imported)
}
pub fn bulk_document_name(library_id: &str) -> String {
let cleaned: String = library_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
.collect();
let trimmed = cleaned.trim_matches(['_', '.']).to_owned();
if trimmed.is_empty() {
"kicad-part".into()
} else {
trimmed
}
}
pub fn grade(part: &KicadPart) -> Grade {
if part.model.is_some() {
Grade::Full
} else if part.footprint.is_none() {
Grade::NoFootprint
} else if part.notes.iter().any(|note| note.contains("did not import")) {
Grade::ModelUnreadable
} else {
Grade::NoModel
}
}
fn settle_with_memo(part: &mut KicadPart, memo: &mut HashMap<String, ModelFile>) {
let Some(step) = part.step.take() else {
return;
};
if let Some(cached) = memo.get(&step.step_file) {
let mut reused = cached.clone();
reused.model = step.model.clone();
reused.step_file = step.step_file.clone();
part.settle_model(Ok(reused));
return;
}
let parsed = parse_step(&step);
if let Ok(model) = &parsed {
if memo.len() < MODEL_MEMO {
memo.insert(step.step_file.clone(), model.clone());
}
}
part.settle_model(parsed);
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Progress {
pub libraries_total: usize,
pub libraries_done: usize,
pub libraries_failed: usize,
pub library: String,
pub symbols_total: usize,
pub symbols_done: usize,
pub symbol: String,
pub imported: usize,
pub skipped: usize,
pub failed: usize,
pub notes: Vec<String>,
pub finished: bool,
pub cancelled: bool,
pub error: Option<String>,
}
impl Progress {
pub fn fraction(&self) -> f32 {
if self.libraries_total == 0 {
return 0.0;
}
let within = if self.symbols_total == 0 {
0.0
} else {
(self.symbols_done as f32 / self.symbols_total as f32).clamp(0.0, 1.0)
};
((self.libraries_done as f32 + within) / self.libraries_total as f32).clamp(0.0, 1.0)
}
pub fn line(&self) -> String {
if let Some(error) = &self.error {
return format!("the sweep stopped: {error}");
}
let head = if self.finished {
if self.cancelled {
"cancelled".to_string()
} else {
"finished".to_string()
}
} else if self.symbols_total == 0 {
format!("{} \u{2014} reading its symbols", self.library)
} else {
format!("{} {}/{} \u{2014} {}", self.library, self.symbols_done, self.symbols_total, self.symbol)
};
format!(
"{head} | library {}/{} | {} imported, {} already done, {} failed",
self.libraries_done.min(self.libraries_total),
self.libraries_total,
self.imported,
self.skipped,
self.failed,
)
}
fn note(&mut self, note: String) {
self.notes.push(trim_note(¬e));
if self.notes.len() > SHOWN_NOTES {
self.notes.remove(0);
}
}
}
pub struct Sweep {
pub cache_root: PathBuf,
pub tag: String,
pub symbols_dir: PathBuf,
pub library: KicadLibrary,
pub libraries: Vec<String>,
pub force: bool,
}
pub fn sweep(
plan: &Sweep,
files: &dyn KicadFiles,
ledger: &mut Ledger,
writer: &dyn PartWriter,
cancel: &AtomicBool,
report: &mut dyn FnMut(&Progress),
) -> Progress {
let mut progress = Progress { libraries_total: plan.libraries.len(), ..Progress::default() };
report(&progress);
for (index, library) in plan.libraries.iter().enumerate() {
if cancel.load(Ordering::Relaxed) {
progress.cancelled = true;
break;
}
progress.libraries_done = index;
progress.library = library.clone();
progress.symbols_total = 0;
progress.symbols_done = 0;
progress.symbol.clear();
report(&progress);
let file = format!("{library}.kicad_sym");
let path = plan.symbols_dir.join(&file);
let read = files
.read(&path.to_string_lossy())
.ok_or_else(|| format!("{} could not be read", path.display()))
.and_then(|bytes| read_symbol_library(&file, &bytes));
let symbols = match read {
Ok((symbols, _warnings)) => symbols,
Err(error) => {
progress.libraries_failed += 1;
progress.note(format!("{library}: {error}"));
progress.libraries_done = index + 1;
report(&progress);
continue;
}
};
progress.symbols_total = symbols.len();
report(&progress);
let mut memo: HashMap<String, ModelFile> = HashMap::new();
for symbol in symbols {
if cancel.load(Ordering::Relaxed) {
progress.cancelled = true;
break;
}
let key = symbol.library_id.clone();
if !plan.force && ledger.settled(&key) {
progress.skipped += 1;
progress.symbols_done += 1;
continue;
}
progress.symbol = key.clone();
report(&progress);
let mut part = part_from_symbol(symbol, &FootprintChoice::Named, &plan.library, files);
settle_with_memo(&mut part, &mut memo);
let document = bulk_document_name(&key);
let mut notes: Vec<String> =
part.notes.iter().take(LEDGER_NOTES).map(|note| trim_note(note)).collect();
let outcome = match write_part(writer, &document, &part) {
Ok(_) => Outcome::Done(grade(&part)),
Err(error) => Outcome::Failed(error),
};
let written = matches!(outcome, Outcome::Done(_));
if let Outcome::Failed(reason) = &outcome {
progress.failed += 1;
progress.note(format!("{key}: {reason}"));
notes.clear();
} else {
progress.imported += 1;
}
let record = Record {
key,
outcome,
document: if written { document } else { String::new() },
notes,
at: now(),
};
if let Err(error) = ledger.append(record) {
progress.error = Some(error);
progress.finished = true;
report(&progress);
return progress;
}
progress.symbols_done += 1;
report(&progress);
}
if progress.cancelled {
break;
}
progress.libraries_done = index + 1;
report(&progress);
}
progress.symbol.clear();
progress.finished = true;
report(&progress);
progress
}
pub struct BulkRun {
updates: std::sync::mpsc::Receiver<Progress>,
cancel: Arc<AtomicBool>,
pub progress: Progress,
pub ledger: PathBuf,
pub resumed: usize,
}
impl BulkRun {
pub fn poll(&mut self) -> bool {
let mut moved = false;
loop {
match self.updates.try_recv() {
Ok(progress) => {
self.progress = progress;
moved = true;
}
Err(std::sync::mpsc::TryRecvError::Empty) => return moved,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
if !self.progress.finished {
self.progress.finished = true;
if self.progress.error.is_none() && !self.progress.cancelled {
self.progress.error = Some("the sweep stopped without saying why".into());
}
moved = true;
}
return moved;
}
}
}
}
pub fn stop(&self) {
self.cancel.store(true, Ordering::Relaxed);
}
pub fn stopping(&self) -> bool {
self.cancel.load(Ordering::Relaxed)
}
pub fn finished(&self) -> bool {
self.progress.finished
}
}
pub fn start(
plan: Sweep,
files: Box<dyn KicadFiles + Send>,
writer: Box<dyn PartWriter + Send>,
) -> Result<BulkRun, String> {
let mut ledger = Ledger::open(&plan.cache_root, &plan.tag)?;
let resumed = ledger.counts().0;
let path = ledger.path().to_path_buf();
let cancel = Arc::new(AtomicBool::new(false));
let (send, updates) = std::sync::mpsc::channel();
let flag = Arc::clone(&cancel);
std::thread::spawn(move || {
let mut report = |progress: &Progress| {
let _ = send.send(progress.clone());
};
sweep(&plan, files.as_ref(), &mut ledger, writer.as_ref(), &flag, &mut report);
});
Ok(BulkRun {
updates,
cancel,
progress: Progress::default(),
ledger: path,
resumed,
})
}