app
Adom Chip Fetcher
Public Made by Adomby adom
Your whole parts library — manufacturer-grade chip CAD (symbol, footprint, 3D) one tap from your EDA tool.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
//! Adom Wiki as the FIRST part-source on the sourcing ladder.
//!
//! The wiki (https://wiki.adom.inc) hosts published `component` pages whose git
//! repo carries a complete, Adom-vetted CAD bundle — `<mpn>.kicad_sym`,
//! `<mpn>.kicad_mod`, STEP/GLB, datasheet PDF, and the 3D-outline SVGs. If a
//! part already exists there we REUSE that bundle instead of re-fetching and
//! rebuilding from external vendors: faster, free, and guaranteed
//! Adom-consistent geometry + provenance.
//!
//! Reads only — the public read API needs no token. There is no HTTP crate in
//! this tree, so we shell `curl` (the same dependency-free style the rest of the
//! CLI uses to drive sibling binaries).
use crate::import::{self, FileProvenance};
use crate::sourcing::{self, Source};
use anyhow::{anyhow, Context, Result};
use serde_json::Value;
use std::path::{Path, PathBuf};
const WIKI: &str = "https://wiki.adom.inc";
/// A wiki `component` page that matched an MPN and carries a usable bundle.
pub struct WikiHit {
pub slug: String,
pub title: String,
pub url: String, // human page URL — recorded as provenance
}
/// Normalize an MPN for matching: lowercase, drop all non-alphanumerics. So
/// "B2B-ZR" == "b2bzr" == "B2B_ZR". Used to compare the requested MPN against a
/// page's component.mpn and to match canonical file prefixes.
fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect()
}
/// Result of a successful reuse-from-wiki.
pub struct WikiPull {
pub url: String,
pub files_added: Vec<String>,
}
// ---------- tiny curl-backed HTTP (no HTTP crate in-tree) ----------
fn curl_json(url: &str) -> Option<Value> {
let out = std::process::Command::new("curl")
.args(["-fsSL", "--max-time", "20", url])
.output()
.ok()?;
if !out.status.success() {
return None;
}
serde_json::from_slice(&out.stdout).ok()
}
fn curl_download(url: &str, dest: &Path) -> Result<()> {
let out = std::process::Command::new("curl")
.args(["-fsSL", "--max-time", "60", "-o"])
.arg(dest)
.arg(url)
.output()
.with_context(|| format!("curl {url}"))?;
if !out.status.success() {
return Err(anyhow!(
"curl {url} failed: {}",
String::from_utf8_lossy(&out.stderr)
));
}
Ok(())
}
// ---------- matching ----------
/// Candidate slugs to try as an exact page lookup, most-specific first.
fn slug_candidates(mpn: &str) -> Vec<String> {
let lower = mpn.to_ascii_lowercase();
let alnum: String = lower.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
let dashed: String = lower
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let mut v = vec![lower.clone(), dashed, alnum];
v.dedup();
v
}
/// The MPN with hyphens stripped — the wiki's full-text search throws on
/// hyphens (known bug), so we never send a raw hyphenated query.
fn search_safe(mpn: &str) -> String {
mpn.chars().filter(|c| *c != '-').collect()
}
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
/// Is this page a `component` page? Returns (title, human URL, component.mpn).
fn component_meta(slug: &str) -> Option<(String, String, String)> {
let pj = curl_json(&format!("{WIKI}/api/v1/pages/{slug}/files/page.json"))?;
if pj.get("type").and_then(|t| t.as_str()) != Some("component") {
return None;
}
let title = pj.get("title").and_then(|t| t.as_str()).unwrap_or(slug).to_string();
// The canonical MPN: component.mpn, falling back to the slug.
let page_mpn = pj
.get("component")
.and_then(|c| c.get("mpn"))
.and_then(|m| m.as_str())
.filter(|s| !s.is_empty())
.unwrap_or(slug)
.to_string();
Some((title, page_url(slug, &pj), page_mpn))
}
/// Human page URL — `wiki.adom.inc/<org>/<slug>` when we know the org, else
/// `wiki.adom.inc/<slug>`.
fn page_url(slug: &str, meta: &Value) -> String {
let org = meta
.get("org_name")
.or_else(|| meta.get("org").and_then(|o| o.get("name")))
.and_then(|o| o.as_str())
.unwrap_or("");
if org.is_empty() {
format!("{WIKI}/{slug}")
} else {
format!("{WIKI}/{org}/{slug}")
}
}
/// Collect candidate component slugs for an MPN via the three documented paths,
/// in priority order: exact slug → discover(triggers) → full-text search.
fn candidate_slugs(mpn: &str) -> Vec<String> {
fn push(s: String, slugs: &mut Vec<String>) {
if !s.is_empty() && !slugs.contains(&s) {
slugs.push(s);
}
}
let mut slugs: Vec<String> = Vec::new();
// 1. exact / normalized slug
for cand in slug_candidates(mpn) {
push(cand, &mut slugs);
}
// 2. discovery by trigger
if let Some(v) = curl_json(&format!("{WIKI}/api/v1/discover?triggers={}", url_encode(mpn))) {
if let Some(arr) = v.as_array() {
for r in arr {
if r.get("type").and_then(|t| t.as_str()) == Some("component") {
if let Some(s) = r.get("slug").and_then(|s| s.as_str()) {
push(s.to_string(), &mut slugs);
}
}
}
}
}
// 3. full-text search (hyphens stripped — FTS throws on them)
if let Some(v) = curl_json(&format!("{WIKI}/api/v1/search?q={}", url_encode(&search_safe(mpn)))) {
if let Some(arr) = v.get("results").and_then(|r| r.as_array()) {
for r in arr {
if r.get("type").and_then(|t| t.as_str()) == Some("component") {
if let Some(s) = r.get("slug").and_then(|s| s.as_str()) {
push(s.to_string(), &mut slugs);
}
}
}
}
}
slugs
}
/// List a page's file paths (flat). Handles `{ "files": [ { "path": .. } ] }`.
fn list_files(slug: &str) -> Vec<String> {
let v = match curl_json(&format!("{WIKI}/api/v1/pages/{slug}/files")) {
Some(v) => v,
None => return vec![],
};
let arr = v
.get("files")
.and_then(|f| f.as_array())
.or_else(|| v.as_array())
.cloned()
.unwrap_or_default();
arr.iter()
.filter(|e| e.get("type").and_then(|t| t.as_str()) != Some("tree"))
.filter_map(|e| {
e.get("path")
.or_else(|| e.get("name"))
.and_then(|p| p.as_str())
.map(|s| s.to_string())
})
.collect()
}
/// How a wiki file maps into the bundle. `Core` goes through the importer
/// (magic-byte validation + canonical `<mpn>.<ext>` naming + per-source
/// variant); `Aux(name)` is copied in directly under `name`.
enum Pull {
Core,
Aux(String),
}
/// Classify a wiki file for pulling. `mpn` is the requested MPN — used both to
/// match the canonical file prefix and to name targets. (We key off the
/// requested MPN, not the page's component.mpn, because the on-disk wiki files
/// are named after the real part number and some pages glue a description onto
/// component.mpn.) Only the CANONICAL file of each type is pulled — variant /
/// compare GLBs like `B2B-ZR-v2.glb` or `B2B-ZR-compare.glb` are skipped.
fn classify(mpn: &str, path: &str) -> Option<Pull> {
let base = path.rsplit('/').next().unwrap_or(path);
let bl = base.to_ascii_lowercase();
let pm = norm(mpn);
// 3D-outline SVGs: <prefix>-3d-outline-<style>.svg, prefix == the part.
if bl.ends_with(".svg") {
if let Some(idx) = bl.find("-3d-outline-") {
if norm(&base[..idx]) == pm {
return Some(Pull::Aux(format!("{mpn}{}", &base[idx..])));
}
}
return None;
}
// Everything else keys on (stem, ext); stem must BE the part (not a variant).
let (stem, ext) = base.rsplit_once('.')?;
if norm(stem) != pm {
return None;
}
match ext.to_ascii_lowercase().as_str() {
"kicad_sym" | "kicad_mod" | "step" | "stp" | "pdf" => Some(Pull::Core),
"glb" => Some(Pull::Aux(format!("{mpn}.glb"))),
"wrl" => Some(Pull::Aux(format!("{mpn}.wrl"))),
_ => None,
}
}
/// Is `path` a core CAD file for `mpn`? Its presence is what makes a page a
/// USABLE bundle (a metadata-only component page has none → fall through).
fn has_core(mpn: &str, path: &str) -> bool {
matches!(classify(mpn, path), Some(Pull::Core))
&& !path.to_ascii_lowercase().ends_with(".pdf")
}
/// Find the component page that actually carries a CAD bundle AND whose MPN
/// matches the request (normalized). Without the MPN check, a loose
/// discover/search hit (or the wiki returning a popular page) would wrongly
/// "match" any query — verified the hard way: a nonsense MPN matched B2B-ZR.
pub fn find_component(mpn: &str) -> Option<WikiHit> {
let want = norm(mpn);
for slug in candidate_slugs(mpn) {
let (title, url, page_mpn) = match component_meta(&slug) {
Some(m) => m,
None => continue, // not a component page (or 404)
};
// Match on the page's component.mpn OR its slug — some pages glue a
// description onto component.mpn (e.g. "BQ7692003PWR — BQ76920 …"), so the
// clean slug is the reliable identity. Either exact (normalized) match is
// accepted; a loose discover/search hit for a different part is rejected.
if norm(&page_mpn) != want && norm(&slug) != want {
continue;
}
let files = list_files(&slug);
if files.iter().any(|f| has_core(mpn, f)) {
return Some(WikiHit { slug, title, url });
}
// matches the MPN but metadata-only (no CAD) → not usable, keep looking
}
None
}
// ---------- pulling the bundle ----------
/// Query the wiki and, if a usable component bundle exists, pull it into
/// `library/<mpn>/` with canonical names + `adom-wiki` provenance. Returns
/// `Ok(None)` when nothing usable matched (→ caller falls through to the
/// external ladder). Reads only.
pub fn try_fetch(mpn: &str) -> Result<Option<WikiPull>> {
let hit = match find_component(mpn) {
Some(h) => h,
None => return Ok(None),
};
println!("Adom Wiki: reusing published bundle \"{}\" from {} ({})", hit.title, hit.url, hit.slug);
let dir = crate::library::ensure_dir(mpn)?;
let stage = std::env::temp_dir().join(format!("cf-wiki-{}", mpn));
let _ = std::fs::remove_dir_all(&stage);
std::fs::create_dir_all(&stage)?;
let prov = FileProvenance {
discovery_source: "adom-wiki".into(),
content_origin: "adom-wiki".into(),
url: hit.url.clone(),
};
let mut added: Vec<String> = Vec::new();
for path in list_files(&hit.slug) {
let pull = match classify(mpn, &path) {
Some(p) => p,
None => continue, // README / page.json / variant GLB / unrelated file
};
let base = path.rsplit('/').next().unwrap_or(&path).to_string();
let staged: PathBuf = stage.join(&base);
let file_url = format!(
"{WIKI}/api/v1/pages/{}/files/{}",
hit.slug,
path.split('/').map(url_encode).collect::<Vec<_>>().join("/")
);
if let Err(e) = curl_download(&file_url, &staged) {
eprintln!("WARN: skipped {base}: {e}");
continue;
}
match pull {
Pull::Core => {
// Core CAD + datasheet → through the importer: magic-byte
// validation, canonical <mpn>.<ext> naming, per-source variant,
// provenance.
match import::import_path_with_provenance(&staged, Some(mpn), Some(&prov)) {
Ok(rep) => added.extend(rep.files_added),
Err(e) => eprintln!("WARN: import {base} failed: {e}"),
}
}
Pull::Aux(name) => {
// GLB / WRL / 3D-outline SVGs → copy in directly + record provenance.
let target = dir.join(&name);
if let Err(e) = std::fs::copy(&staged, &target) {
eprintln!("WARN: copy {base} failed: {e}");
continue;
}
let _ = import::record_file_provenance(&dir, &name, &prov);
added.push(name);
}
}
}
let _ = std::fs::remove_dir_all(&stage);
// Record the wiki as the (winning) ladder step + stamp info.json provenance.
let _ = sourcing::append_chain(mpn, Source::AdomWiki, "OK", Some(&hit.url));
let _ = stamp_info_provenance(&dir, mpn, &hit.url);
if added.is_empty() {
// Found a page but pulled nothing usable after all → fall through.
return Ok(None);
}
Ok(Some(WikiPull {
url: hit.url,
files_added: added,
}))
}
/// Record the top-level discovery provenance on info.json so the source carat
/// shows "Adom Wiki" as a selectable method like the other ladder sources.
fn stamp_info_provenance(dir: &Path, mpn: &str, url: &str) -> Result<()> {
let path = dir.join("info.json");
let mut info: Value = std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({ "mpn": mpn }));
info["discovery_source"] = Value::String("adom-wiki".into());
info["content_origin"] = Value::String("adom-wiki".into());
info["adom_wiki_url"] = Value::String(url.into());
std::fs::write(&path, serde_json::to_string_pretty(&info)?)?;
Ok(())
}