453 lines
16 KiB
Rust
453 lines
16 KiB
Rust
// Copyright (C) 2026 Hector van der Aa <hector@h3cx.dev>
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
mod config;
|
|
mod desktop;
|
|
mod destination;
|
|
mod doctor;
|
|
mod gui;
|
|
mod importer;
|
|
mod matching;
|
|
mod models;
|
|
mod preview;
|
|
mod sexpr;
|
|
mod sources;
|
|
mod storage;
|
|
mod symbols;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use clap::{Parser, Subcommand};
|
|
use config::Config;
|
|
use importer::{Request, Session};
|
|
use notify::{
|
|
Event, EventKind, RecursiveMode, Watcher,
|
|
event::{AccessKind, AccessMode},
|
|
};
|
|
use std::{
|
|
collections::{BTreeMap, BTreeSet},
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
sync::mpsc,
|
|
time::{Duration, Instant, SystemTime},
|
|
};
|
|
use storage::Record;
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
version,
|
|
about = "Import downloaded KiCad components into global or portable project libraries"
|
|
)]
|
|
struct Cli {
|
|
#[arg(long, global = true)]
|
|
config: Option<PathBuf>,
|
|
#[arg(long, global = true)]
|
|
noninteractive: bool,
|
|
#[command(subcommand)]
|
|
command: Option<Commands>,
|
|
}
|
|
#[derive(Subcommand)]
|
|
enum Commands {
|
|
// Isolate the native event loop on its own main thread while the watcher
|
|
// continues receiving downloads. This is the same executable, not a helper.
|
|
#[command(name = "__dialog", hide = true)]
|
|
Dialog,
|
|
/// Watch configured download directories (also the default command).
|
|
Watch,
|
|
/// Import a ZIP archive or extracted component directory.
|
|
Import {
|
|
source: PathBuf,
|
|
#[arg(long, conflicts_with = "project")]
|
|
global: bool,
|
|
#[arg(long)]
|
|
project: Option<PathBuf>,
|
|
#[arg(long)]
|
|
dry_run: bool,
|
|
#[arg(long)]
|
|
force: bool,
|
|
},
|
|
/// List pending and failed imports; use --all for full history.
|
|
Pending {
|
|
#[arg(long)]
|
|
all: bool,
|
|
},
|
|
/// Retry an import listed by pending.
|
|
Retry {
|
|
id: String,
|
|
#[arg(long)]
|
|
dry_run: bool,
|
|
},
|
|
/// Check managed nicknames; --fix backs up and migrates libraries/references.
|
|
Doctor {
|
|
#[arg(long, required_unless_present = "project", conflicts_with = "project")]
|
|
global: bool,
|
|
#[arg(long)]
|
|
project: Option<PathBuf>,
|
|
#[arg(long)]
|
|
fix: bool,
|
|
},
|
|
/// Show project suggestions from running KiCad processes.
|
|
Discover,
|
|
}
|
|
|
|
fn execute(cfg: &Config, req: &Request, mut record: Record, session: &mut Session) -> Result<()> {
|
|
match importer::run(cfg, req, &mut record, session) {
|
|
Ok(()) => Ok(()),
|
|
Err(error) => {
|
|
let message = format!("{error:#}");
|
|
if message.contains("another UniLoader import is active") {
|
|
return Err(error);
|
|
}
|
|
record.status = if message.contains("pending:") {
|
|
"pending"
|
|
} else if message.contains("skipped:") {
|
|
"skipped"
|
|
} else {
|
|
"failed"
|
|
}
|
|
.into();
|
|
record.message = message.clone();
|
|
if !req.dry_run {
|
|
record.save()?;
|
|
}
|
|
bail!(
|
|
"{}: {message} (import {}; retry with `uniloader retry {}`)",
|
|
record.source.display(),
|
|
record.id,
|
|
record.id
|
|
)
|
|
}
|
|
}
|
|
}
|
|
fn new_record(source: PathBuf, req: &Request) -> Result<Record> {
|
|
let source = fs::canonicalize(source)?;
|
|
let hash = storage::source_hash(&source)?;
|
|
let mut record = Record::new(source, hash);
|
|
if req.global || req.project.is_some() {
|
|
let target = if req.global {
|
|
"global".to_owned()
|
|
} else {
|
|
config::expand(req.project.as_ref().unwrap())
|
|
.to_string_lossy()
|
|
.into_owned()
|
|
};
|
|
record.id = format!("{}-{}", record.id, &storage::hash(target.as_bytes())[..8]);
|
|
}
|
|
Ok(record)
|
|
}
|
|
struct Waiting {
|
|
size: u64,
|
|
modified: Option<SystemTime>,
|
|
changed: Instant,
|
|
first: Instant,
|
|
}
|
|
fn zip_file(p: &Path) -> bool {
|
|
p.is_file() && p.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip"))
|
|
}
|
|
fn complete_zip(p: &Path) -> Result<bool> {
|
|
let mut zip = zip::ZipArchive::new(fs::File::open(p)?)?;
|
|
let mut supported = false;
|
|
for i in 0..zip.len() {
|
|
let f = zip.by_index(i)?;
|
|
let name = f.name().to_ascii_lowercase();
|
|
if name.ends_with(".kicad_sym") || name.ends_with(".kicad_mod") {
|
|
supported = true;
|
|
}
|
|
}
|
|
Ok(supported)
|
|
}
|
|
fn event_requires_scan(event: &Event) -> bool {
|
|
// Reading a watched directory emits OPEN events on Linux, including from
|
|
// our own scans. Only a writer closing a file can make access relevant.
|
|
event.need_rescan()
|
|
|| !matches!(event.kind, EventKind::Access(_))
|
|
|| matches!(
|
|
event.kind,
|
|
EventKind::Access(AccessKind::Close(AccessMode::Write))
|
|
)
|
|
}
|
|
fn watch(cfg: Config, noninteractive: bool) -> Result<()> {
|
|
let _watch_lock = storage::Lock::watcher()?;
|
|
let (events_tx, events_rx) = mpsc::channel();
|
|
let mut watcher = notify::recommended_watcher(move |event: notify::Result<Event>| {
|
|
// Filter before queuing so reads cannot keep the receive loop awake.
|
|
if event.as_ref().map_or(true, event_requires_scan) {
|
|
let _ = events_tx.send(event);
|
|
}
|
|
})?;
|
|
for dir in &cfg.watch.directories {
|
|
if !dir.is_dir() {
|
|
bail!(
|
|
"watch directory {} does not exist; create it or configure watch.directories",
|
|
dir.display()
|
|
);
|
|
}
|
|
watcher.watch(dir, RecursiveMode::NonRecursive)?;
|
|
println!("Watching {}", dir.display());
|
|
}
|
|
let (jobs_tx, jobs_rx) = mpsc::channel::<Record>();
|
|
let (done_tx, done_rx) = mpsc::channel::<String>();
|
|
let worker_cfg = cfg.clone();
|
|
std::thread::spawn(move || {
|
|
let mut session = Session::default();
|
|
let req = Request {
|
|
noninteractive,
|
|
..Request::default()
|
|
};
|
|
for record in jobs_rx {
|
|
let id = record.id.clone();
|
|
if let Err(e) = execute(&worker_cfg, &req, record, &mut session) {
|
|
eprintln!("{e:#}");
|
|
desktop::notify("Import needs attention. Run uniloader pending for details.");
|
|
} else {
|
|
desktop::notify("Component import finished. See the service log for details.");
|
|
}
|
|
let _ = done_tx.send(id);
|
|
}
|
|
});
|
|
let mut waiting: BTreeMap<PathBuf, Waiting> = BTreeMap::new();
|
|
let mut submitted: BTreeSet<String> = BTreeSet::new();
|
|
let mut seen: BTreeMap<PathBuf, (u64, Option<SystemTime>)> = BTreeMap::new();
|
|
// A restart resumes pending choices once, without endlessly reopening closed dialogs.
|
|
for r in storage::records()? {
|
|
if ["pending", "importing"].contains(&r.status.as_str()) && r.source.exists() {
|
|
submitted.insert(r.id.clone());
|
|
jobs_tx.send(r)?;
|
|
}
|
|
}
|
|
let scan = |waiting: &mut BTreeMap<PathBuf, Waiting>,
|
|
seen: &BTreeMap<PathBuf, (u64, Option<SystemTime>)>| {
|
|
for dir in &cfg.watch.directories {
|
|
if let Ok(entries) = fs::read_dir(dir) {
|
|
for entry in entries.flatten() {
|
|
let p = entry.path();
|
|
if !zip_file(&p) {
|
|
continue;
|
|
}
|
|
if let Ok(m) = entry.metadata() {
|
|
let signature = (m.len(), m.modified().ok());
|
|
if seen.get(&p) == Some(&signature) {
|
|
continue;
|
|
}
|
|
waiting
|
|
.entry(p)
|
|
.and_modify(|w| {
|
|
if (w.size, w.modified) != signature {
|
|
w.size = signature.0;
|
|
w.modified = signature.1;
|
|
w.changed = Instant::now();
|
|
}
|
|
})
|
|
.or_insert(Waiting {
|
|
size: signature.0,
|
|
modified: signature.1,
|
|
changed: Instant::now(),
|
|
first: Instant::now(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
scan(&mut waiting, &seen);
|
|
let mut last_scan = Instant::now();
|
|
loop {
|
|
match events_rx.recv_timeout(Duration::from_millis(500)) {
|
|
Ok(event) => {
|
|
let mut rescan = false;
|
|
// Bound draining so sustained downloads cannot starve retries.
|
|
for event in std::iter::once(event).chain(events_rx.try_iter().take(1023)) {
|
|
match event {
|
|
Ok(_) => rescan = true,
|
|
Err(e) => eprintln!("watch error: {e}"),
|
|
}
|
|
}
|
|
if rescan {
|
|
scan(&mut waiting, &seen);
|
|
}
|
|
}
|
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
|
Err(mpsc::RecvTimeoutError::Disconnected) => bail!("filesystem watcher disconnected"),
|
|
}
|
|
if last_scan.elapsed() >= Duration::from_secs(5) {
|
|
scan(&mut waiting, &seen);
|
|
last_scan = Instant::now();
|
|
}
|
|
for id in done_rx.try_iter() {
|
|
submitted.remove(&id);
|
|
}
|
|
let ready = waiting
|
|
.iter()
|
|
.filter(|(_, w)| w.changed.elapsed() >= Duration::from_secs(cfg.watch.settle_seconds))
|
|
.map(|(p, _)| p.clone())
|
|
.collect::<Vec<_>>();
|
|
for p in ready {
|
|
if !p.exists() {
|
|
waiting.remove(&p);
|
|
continue;
|
|
}
|
|
match complete_zip(&p) {
|
|
Ok(supported) => {
|
|
let w = waiting.remove(&p).unwrap();
|
|
seen.insert(p.clone(), (w.size, w.modified));
|
|
if !supported {
|
|
continue;
|
|
}
|
|
let req = Request::default();
|
|
match new_record(p.clone(), &req) {
|
|
Ok(r) => {
|
|
let history = storage::records()?;
|
|
// Completed files stay ignored until --force/manual import; pending
|
|
// files are resumed once at startup, not repeatedly every scan.
|
|
if history
|
|
.iter()
|
|
.any(|old| old.hash == r.hash && old.source == r.source)
|
|
|| !submitted.insert(r.id.clone())
|
|
{
|
|
continue;
|
|
}
|
|
r.save()?;
|
|
jobs_tx.send(r)?;
|
|
}
|
|
Err(e) => eprintln!("{}: {e:#}", p.display()),
|
|
}
|
|
}
|
|
Err(e) => {
|
|
if waiting[&p].first.elapsed() >= Duration::from_secs(cfg.watch.retry_seconds) {
|
|
let w = waiting.remove(&p).unwrap();
|
|
seen.insert(p.clone(), (w.size, w.modified));
|
|
if let Ok(mut r) = new_record(p.clone(), &Request::default()) {
|
|
r.status = "failed".into();
|
|
r.message = format!("download never became a readable ZIP: {e:#}");
|
|
r.save()?;
|
|
}
|
|
eprintln!(
|
|
"{}: ZIP still unreadable after retry window: {e:#}",
|
|
p.display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main() -> Result<()> {
|
|
let cli = Cli::parse();
|
|
if matches!(cli.command, Some(Commands::Dialog)) {
|
|
return desktop::run_internal_dialog();
|
|
}
|
|
let cfg = Config::load(cli.config.as_deref())?;
|
|
match cli.command.unwrap_or(Commands::Watch) {
|
|
Commands::Dialog => unreachable!("handled before configuration loading"),
|
|
Commands::Watch => watch(cfg, cli.noninteractive),
|
|
Commands::Import {
|
|
source,
|
|
global,
|
|
project,
|
|
dry_run,
|
|
force,
|
|
} => {
|
|
let req = Request {
|
|
global,
|
|
project,
|
|
dry_run,
|
|
noninteractive: cli.noninteractive,
|
|
force,
|
|
};
|
|
let record = new_record(source, &req)?;
|
|
execute(&cfg, &req, record, &mut Session::default())
|
|
}
|
|
Commands::Pending { all } => {
|
|
for r in storage::records()? {
|
|
if all || ["pending", "failed", "importing"].contains(&r.status.as_str()) {
|
|
println!(
|
|
"{} [{}] {}\n {}\n {}",
|
|
r.id,
|
|
r.status,
|
|
r.source.display(),
|
|
r.destination.as_deref().unwrap_or("destination not chosen"),
|
|
r.message
|
|
);
|
|
if all {
|
|
for p in &r.changed {
|
|
println!(" Changed: {}", p.display());
|
|
}
|
|
for p in &r.backups {
|
|
println!(" Backup: {}", p.display());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
Commands::Retry { id, dry_run } => {
|
|
let record = storage::records()?
|
|
.into_iter()
|
|
.find(|r| r.id == id)
|
|
.context("unknown import ID; run uniloader pending")?;
|
|
let hash = storage::source_hash(&record.source)?;
|
|
if hash != record.hash {
|
|
bail!(
|
|
"source changed since queued; use `uniloader import` to review it as a new component"
|
|
);
|
|
}
|
|
execute(
|
|
&cfg,
|
|
&Request {
|
|
dry_run,
|
|
noninteractive: cli.noninteractive,
|
|
force: true,
|
|
..Request::default()
|
|
},
|
|
record,
|
|
&mut Session::default(),
|
|
)
|
|
}
|
|
Commands::Doctor {
|
|
global: _,
|
|
project,
|
|
fix,
|
|
} => doctor::run(&cfg, project.as_deref(), fix),
|
|
Commands::Discover => {
|
|
let p = desktop::projects();
|
|
if p.is_empty() {
|
|
println!("No project suggestions found. Browse or use --project.");
|
|
} else {
|
|
for path in p {
|
|
println!("{} (process suggestion)", path.display());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod watch_tests {
|
|
use super::*;
|
|
use notify::event::{CreateKind, Flag, ModifyKind, RemoveKind};
|
|
|
|
#[test]
|
|
fn scans_changes_and_write_closes_but_ignores_read_access() {
|
|
for kind in [
|
|
EventKind::Any,
|
|
EventKind::Create(CreateKind::File),
|
|
EventKind::Modify(ModifyKind::Any),
|
|
EventKind::Remove(RemoveKind::File),
|
|
EventKind::Access(AccessKind::Close(AccessMode::Write)),
|
|
] {
|
|
assert!(event_requires_scan(&Event::new(kind)), "{kind:?}");
|
|
}
|
|
for kind in [
|
|
AccessKind::Any,
|
|
AccessKind::Read,
|
|
AccessKind::Open(AccessMode::Read),
|
|
AccessKind::Open(AccessMode::Write),
|
|
AccessKind::Close(AccessMode::Read),
|
|
] {
|
|
let event = Event::new(EventKind::Access(kind));
|
|
assert!(!event_requires_scan(&event), "{kind:?}");
|
|
assert!(event_requires_scan(&event.set_flag(Flag::Rescan)));
|
|
}
|
|
}
|
|
}
|