use crate::plm::client::PlmClient;
use serde_json::Value;
pub fn content_hash(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
}
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
pub struct ImportedRow {
pub id: String,
#[serde(default)]
pub number: String,
#[serde(default)]
pub latest_revision_id: String,
#[serde(default)]
pub latest_origin: Option<String>,
#[serde(default)]
pub latest_content_hash: String,
#[serde(default)]
pub latest_state: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Action {
Mint,
Finish { part: String, revision: String },
Revise { part: String },
Skip { part: String, reason: String },
}
pub fn decide(existing: Option<&ImportedRow>, hash: &str) -> Action {
let Some(row) = existing else {
return Action::Mint;
};
let skip = |reason: String| Action::Skip { part: row.id.clone(), reason };
match row.latest_origin.as_deref() {
Some("imported") => {}
Some(origin) => {
return skip(format!(
"{} was revised by hand (its latest revision is {origin}) — the library is not written over it",
row.number
))
}
None => {
return Action::Finish { part: row.id.clone(), revision: row.latest_revision_id.clone() };
}
}
if row.latest_state == "draft" {
return Action::Finish { part: row.id.clone(), revision: row.latest_revision_id.clone() };
}
if row.latest_content_hash == hash {
return skip(format!("{} is unchanged", row.number));
}
Action::Revise { part: row.id.clone() }
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Outcome {
Written { part: String, revision: String, created_part: bool },
Skipped { part: String, reason: String },
}
fn body(value: Value) -> Option<Vec<u8>> {
Some(serde_json::to_vec(&value).unwrap_or_default())
}
async fn call(client: &PlmClient, method: &'static str, path: &str, payload: Option<Vec<u8>>) -> Result<Value, String> {
let response = client.call(method, path, payload).await.map_err(|error| error.to_string())?;
Ok(serde_json::from_slice(&response.body).unwrap_or(Value::Null))
}
fn encode(text: &str) -> String {
text.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (byte as char).to_string(),
_ => format!("%{byte:02X}"),
})
.collect()
}
pub async fn find(client: &PlmClient, part_type: &str, library_id: &str) -> Result<Option<ImportedRow>, String> {
let page = call(
client,
"GET",
&format!("/api/parts?limit=2&part_type={}&external_ref={}", encode(part_type), encode(library_id)),
None,
)
.await?;
let rows = page["parts"].as_array().cloned().unwrap_or_default();
Ok(rows.into_iter().next().and_then(|row| serde_json::from_value(row).ok()))
}
async fn write_and_release(client: &PlmClient, part: &str, revision: &str, document: &[u8]) -> Result<(), String> {
let base = crate::plm::identity::revision_path(&part, &revision);
call(client, "POST", &format!("{base}/checkout"), body(serde_json::json!({ "client_id": "brep-app kicad" }))).await?;
let written = call(client, "PUT", &format!("/api/store/doc/{}", super::identity::document_key(part, revision).replace("%", "%25")), Some(document.to_vec())).await;
call(client, "POST", &format!("{base}/checkin"), body(serde_json::json!({}))).await?;
written?;
call(client, "POST", &format!("{base}/state"), body(serde_json::json!({ "to": "released" }))).await?;
Ok(())
}
pub async fn publish_part(
client: &PlmClient,
part_type: &str,
library_id: &str,
name: &str,
document: &[u8],
) -> Result<Outcome, String> {
let hash = content_hash(document);
for attempt in 0..2 {
let existing = find(client, part_type, library_id).await?;
match decide(existing.as_ref(), &hash) {
Action::Skip { part, reason } => return Ok(Outcome::Skipped { part, reason }),
Action::Finish { part, revision } => {
write_and_release(client, &part, &revision, document).await?;
return Ok(Outcome::Written { part, revision, created_part: false });
}
Action::Revise { part } => {
let revision = call(
client,
"POST",
&format!("{}/revisions", crate::plm::identity::part_path(&part)),
body(serde_json::json!({ "origin": "imported" })),
)
.await?;
let revision = revision["id"].as_str().unwrap_or_default().to_string();
write_and_release(client, &part, &revision, document).await?;
return Ok(Outcome::Written { part, revision, created_part: false });
}
Action::Mint => {
let request = serde_json::json!({
"part_type": part_type,
"name": name,
"external_ref": library_id,
"origin": "imported",
"bulk": true,
});
match client.call("POST", "/api/parts", body(request)).await {
Ok(response) => {
let created: Value = serde_json::from_slice(&response.body).unwrap_or(Value::Null);
let part = created["id"].as_str().unwrap_or_default().to_string();
let revision = created["revisions"][0]["id"].as_str().unwrap_or_default().to_string();
write_and_release(client, &part, &revision, document).await?;
return Ok(Outcome::Written { part, revision, created_part: true });
}
Err(error) if error.status() == Some(409) && attempt == 0 => continue,
Err(error) => return Err(error.to_string()),
}
}
}
}
Err(format!("'{library_id}' could not be minted or found"))
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LibraryReport {
pub written: usize,
pub created: usize,
pub skipped: usize,
pub failed: Vec<(String, String)>,
}
pub async fn publish_library(
client: &PlmClient,
store: &dyn crate::store::ModelStore,
part_type: &str,
parts: &[(String, String)],
progress: &mut dyn FnMut(&LibraryReport) -> bool,
) -> LibraryReport {
let mut report = LibraryReport::default();
for (library_id, document) in parts {
let outcome = match store.read(document) {
None => Err(format!("its document '{document}' is not in the store")),
Some(bytes) => {
let name = library_id.rsplit(':').next().unwrap_or(library_id);
publish_part(client, part_type, library_id, name, bytes.as_bytes()).await
}
};
match outcome {
Ok(Outcome::Written { created_part, .. }) => {
report.written += 1;
report.created += usize::from(created_part);
}
Ok(Outcome::Skipped { .. }) => report.skipped += 1,
Err(sentence) => report.failed.push((library_id.clone(), sentence)),
}
if !progress(&report) {
break;
}
}
report
}