nlbn 1.0.12

Fast and reliable EasyEDA/LCSC to KiCad converter with batch processing support
Documentation
use reqwest::Client;
use crate::error::{EasyedaError, Result};
use crate::easyeda::models::{ComponentData, ApiResponse, Model3dInfo};

pub struct EasyedaApi {
    client: Client,
}

impl EasyedaApi {
    pub fn new() -> Self {
        Self {
            client: Client::builder()
                .user_agent("nlbn/1.0.12")
                .build()
                .expect("Failed to create HTTP client"),
        }
    }

    pub async fn get_component_data(&self, lcsc_id: &str) -> Result<ComponentData> {
        let url = format!(
            "https://easyeda.com/api/products/{}/components?version=6.4.19.5",
            lcsc_id
        );

        log::info!("Fetching component data for {}", lcsc_id);

        let response = self.client
            .get(&url)
            .send()
            .await
            .map_err(EasyedaError::ApiRequest)?;

        if !response.status().is_success() {
            return Err(EasyedaError::ComponentNotFound(lcsc_id.to_string()).into());
        }

        let api_response: ApiResponse = response.json()
            .await
            .map_err(|e| EasyedaError::InvalidData(format!("Failed to parse JSON: {}", e)))?;

        if !api_response.success {
            return Err(EasyedaError::ComponentNotFound(lcsc_id.to_string()).into());
        }

        let result = api_response.result
            .ok_or_else(|| EasyedaError::InvalidData("Missing result field".to_string()))?;

        let data_str_obj = result.data_str.as_ref()
            .ok_or_else(|| EasyedaError::InvalidData("Missing dataStr field".to_string()))?;

        log::debug!("data_str_obj type: {:?}", data_str_obj);

        let bbox_x = data_str_obj.get("head")
            .and_then(|h| h.get("x"))
            .and_then(|v| v.as_f64())
            .unwrap_or(0.0);
        let bbox_y = data_str_obj.get("head")
            .and_then(|h| h.get("y"))
            .and_then(|v| v.as_f64())
            .unwrap_or(0.0);

        log::debug!("Extracted bbox: x={}, y={}", bbox_x, bbox_y);

        let data_str = if let Some(shape_array) = data_str_obj.get("shape").and_then(|v| v.as_array()) {
            log::debug!("Found shape array with {} elements", shape_array.len());
            shape_array.iter()
                .filter_map(|v| v.as_str().map(|s| s.to_string()))
                .collect()
        } else {
            log::warn!("data_str_obj doesn't have shape array");
            vec![]
        };

        log::debug!("Final data_str has {} shapes", data_str.len());

        let title = result.title
            .ok_or_else(|| EasyedaError::InvalidData("Missing title field".to_string()))?;

        let manufacturer = data_str_obj.get("head")
            .and_then(|h| h.get("c_para"))
            .and_then(|cp| cp.get("BOM_Manufacturer"))
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();

        let datasheet = result.lcsc.as_ref()
            .and_then(|lcsc| lcsc.get("url"))
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();

        let jlc_id = data_str_obj.get("head")
            .and_then(|h| h.get("c_para"))
            .and_then(|cp| cp.get("BOM_JLCPCB Part Class"))
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();

        log::debug!("Extracted metadata: manufacturer={}, datasheet={}, jlc_id={}",
                   manufacturer, datasheet, jlc_id);

        let (package_detail, package_bbox_x, package_bbox_y, model_3d) = if let Some(pkg) = result.package_detail {
            let pkg_bbox_x = pkg.get("dataStr")
                .and_then(|ds| ds.get("head"))
                .and_then(|h| h.get("x"))
                .and_then(|v| v.as_f64())
                .unwrap_or(0.0);
            let pkg_bbox_y = pkg.get("dataStr")
                .and_then(|ds| ds.get("head"))
                .and_then(|h| h.get("y"))
                .and_then(|v| v.as_f64())
                .unwrap_or(0.0);

            log::debug!("Extracted package bbox: x={}, y={}", pkg_bbox_x, pkg_bbox_y);

            let shapes = if let Some(pkg_data_str) = pkg.get("dataStr") {
                if let Some(shape_array) = pkg_data_str.get("shape").and_then(|v| v.as_array()) {
                    shape_array.iter()
                        .filter_map(|v| v.as_str().map(|s| s.to_string()))
                        .collect()
                } else {
                    vec![]
                }
            } else if pkg.is_array() {
                pkg.as_array()
                    .unwrap()
                    .iter()
                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
                    .collect()
            } else {
                vec![]
            };

            let model_3d = Self::extract_3d_model_from_svgnode(&shapes);
            (shapes, pkg_bbox_x, pkg_bbox_y, model_3d)
        } else {
            (vec![], 0.0, 0.0, None)
        };

        Ok(ComponentData {
            lcsc_id: lcsc_id.to_string(),
            title,
            data_str,
            bbox_x,
            bbox_y,
            package_detail,
            package_bbox_x,
            package_bbox_y,
            model_3d,
            manufacturer,
            datasheet,
            jlc_id,
        })
    }

    fn extract_3d_model_from_svgnode(shapes: &[String]) -> Option<Model3dInfo> {
        for shape in shapes {
            if shape.starts_with("SVGNODE~") {
                let parts: Vec<&str> = shape.split('~').collect();
                if parts.len() > 1 {
                    if let Ok(svg_data) = serde_json::from_str::<serde_json::Value>(parts[1]) {
                        if let Some(attrs) = svg_data.get("attrs") {
                            if let Some(c_etype) = attrs.get("c_etype").and_then(|v| v.as_str()) {
                                if c_etype == "outline3D" {
                                    let uuid = attrs.get("uuid")
                                        .and_then(|v| v.as_str())
                                        .map(|s| s.to_string());
                                    let title = attrs.get("title")
                                        .and_then(|v| v.as_str())
                                        .map(|s| s.to_string());

                                    if let (Some(uuid), Some(title)) = (uuid, title) {
                                        return Some(Model3dInfo { uuid, title });
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
        None
    }

    pub async fn download_3d_obj(&self, uuid: &str) -> Result<Vec<u8>> {
        let url = format!("https://modules.easyeda.com/3dmodel/{}", uuid);
        self.download_with_retry(&url, "OBJ", uuid).await
    }

    pub async fn download_3d_step(&self, uuid: &str) -> Result<Vec<u8>> {
        let url = format!("https://modules.easyeda.com/qAxj6KHrDKw4blvCG8QJPs7Y/{}", uuid);
        self.download_with_retry(&url, "STEP", uuid).await
    }

    async fn download_with_retry(&self, url: &str, model_type: &str, uuid: &str) -> Result<Vec<u8>> {
        const MAX_RETRIES: u32 = 3;

        for attempt in 1..=MAX_RETRIES {
            log::info!("Downloading 3D {} model: {}{}", model_type, uuid,
                if attempt > 1 { format!(" (retry {}/{})", attempt, MAX_RETRIES) } else { String::new() });

            match self.client.get(url).send().await {
                Ok(response) => {
                    if !response.status().is_success() {
                        if attempt == MAX_RETRIES {
                            return Err(EasyedaError::InvalidData(
                                format!("Failed to download {}: {}", model_type, uuid)).into());
                        }
                        tokio::time::sleep(std::time::Duration::from_millis(500 * attempt as u64)).await;
                        continue;
                    }
                    match response.bytes().await {
                        Ok(bytes) => return Ok(bytes.to_vec()),
                        Err(e) => {
                            if attempt == MAX_RETRIES {
                                return Err(EasyedaError::ApiRequest(e).into());
                            }
                            log::warn!("Failed to read {} response body, retrying...", model_type);
                            tokio::time::sleep(std::time::Duration::from_millis(500 * attempt as u64)).await;
                        }
                    }
                }
                Err(e) => {
                    if attempt == MAX_RETRIES {
                        return Err(EasyedaError::ApiRequest(e).into());
                    }
                    log::warn!("Failed to download {} model, retrying...", model_type);
                    tokio::time::sleep(std::time::Duration::from_millis(500 * attempt as u64)).await;
                }
            }
        }
        unreachable!()
    }
}

impl Default for EasyedaApi {
    fn default() -> Self {
        Self::new()
    }
}