1use crate::plm::client::PlmClient;
30use serde_json::Value;
31
32pub fn content_hash(bytes: &[u8]) -> String {
35 use sha2::{Digest, Sha256};
36 Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
37}
38
39#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
42pub struct ImportedRow {
43 pub id: String,
44 #[serde(default)]
45 pub number: String,
46 #[serde(default)]
47 pub latest_revision_id: String,
48 #[serde(default)]
51 pub latest_origin: Option<String>,
52 #[serde(default)]
54 pub latest_content_hash: String,
55 #[serde(default)]
57 pub latest_state: String,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq)]
62pub enum Action {
63 Mint,
65 Finish { part: String, revision: String },
68 Revise { part: String },
70 Skip { part: String, reason: String },
72}
73
74pub fn decide(existing: Option<&ImportedRow>, hash: &str) -> Action {
77 let Some(row) = existing else {
78 return Action::Mint;
79 };
80 let skip = |reason: String| Action::Skip { part: row.id.clone(), reason };
81 match row.latest_origin.as_deref() {
82 Some("imported") => {}
83 Some(origin) => {
84 return skip(format!(
85 "{} was revised by hand (its latest revision is {origin}) — the library is not written over it",
86 row.number
87 ))
88 }
89 None => {
90 return Action::Finish { part: row.id.clone(), revision: row.latest_revision_id.clone() };
91 }
92 }
93 if row.latest_state == "draft" {
94 return Action::Finish { part: row.id.clone(), revision: row.latest_revision_id.clone() };
95 }
96 if row.latest_content_hash == hash {
97 return skip(format!("{} is unchanged", row.number));
98 }
99 Action::Revise { part: row.id.clone() }
100}
101
102#[derive(Clone, Debug, PartialEq, Eq)]
104pub enum Outcome {
105 Written { part: String, revision: String, created_part: bool },
107 Skipped { part: String, reason: String },
108}
109
110fn body(value: Value) -> Option<Vec<u8>> {
111 Some(serde_json::to_vec(&value).unwrap_or_default())
112}
113
114async fn call(client: &PlmClient, method: &'static str, path: &str, payload: Option<Vec<u8>>) -> Result<Value, String> {
115 let response = client.call(method, path, payload).await.map_err(|error| error.to_string())?;
116 Ok(serde_json::from_slice(&response.body).unwrap_or(Value::Null))
117}
118
119fn encode(text: &str) -> String {
120 text.bytes()
121 .map(|byte| match byte {
122 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (byte as char).to_string(),
123 _ => format!("%{byte:02X}"),
124 })
125 .collect()
126}
127
128pub async fn find(client: &PlmClient, part_type: &str, library_id: &str) -> Result<Option<ImportedRow>, String> {
130 let page = call(
131 client,
132 "GET",
133 &format!("/api/parts?limit=2&part_type={}&external_ref={}", encode(part_type), encode(library_id)),
134 None,
135 )
136 .await?;
137 let rows = page["parts"].as_array().cloned().unwrap_or_default();
138 Ok(rows.into_iter().next().and_then(|row| serde_json::from_value(row).ok()))
139}
140
141async fn write_and_release(client: &PlmClient, part: &str, revision: &str, document: &[u8]) -> Result<(), String> {
144 let base = crate::plm::identity::revision_path(&part, &revision);
145 call(client, "POST", &format!("{base}/checkout"), body(serde_json::json!({ "client_id": "brep-app kicad" }))).await?;
146 let written = call(client, "PUT", &format!("/api/store/doc/{}", super::identity::document_key(part, revision).replace("%", "%25")), Some(document.to_vec())).await;
147 call(client, "POST", &format!("{base}/checkin"), body(serde_json::json!({}))).await?;
148 written?;
149 call(client, "POST", &format!("{base}/state"), body(serde_json::json!({ "to": "released" }))).await?;
150 Ok(())
151}
152
153pub async fn publish_part(
158 client: &PlmClient,
159 part_type: &str,
160 library_id: &str,
161 name: &str,
162 document: &[u8],
163) -> Result<Outcome, String> {
164 let hash = content_hash(document);
165 for attempt in 0..2 {
166 let existing = find(client, part_type, library_id).await?;
167 match decide(existing.as_ref(), &hash) {
168 Action::Skip { part, reason } => return Ok(Outcome::Skipped { part, reason }),
169 Action::Finish { part, revision } => {
170 write_and_release(client, &part, &revision, document).await?;
171 return Ok(Outcome::Written { part, revision, created_part: false });
172 }
173 Action::Revise { part } => {
174 let revision = call(
175 client,
176 "POST",
177 &format!("{}/revisions", crate::plm::identity::part_path(&part)),
178 body(serde_json::json!({ "origin": "imported" })),
179 )
180 .await?;
181 let revision = revision["id"].as_str().unwrap_or_default().to_string();
182 write_and_release(client, &part, &revision, document).await?;
183 return Ok(Outcome::Written { part, revision, created_part: false });
184 }
185 Action::Mint => {
186 let request = serde_json::json!({
187 "part_type": part_type,
188 "name": name,
189 "external_ref": library_id,
190 "origin": "imported",
191 "bulk": true,
194 });
195 match client.call("POST", "/api/parts", body(request)).await {
196 Ok(response) => {
197 let created: Value = serde_json::from_slice(&response.body).unwrap_or(Value::Null);
198 let part = created["id"].as_str().unwrap_or_default().to_string();
199 let revision = created["revisions"][0]["id"].as_str().unwrap_or_default().to_string();
200 write_and_release(client, &part, &revision, document).await?;
201 return Ok(Outcome::Written { part, revision, created_part: true });
202 }
203 Err(error) if error.status() == Some(409) && attempt == 0 => continue,
205 Err(error) => return Err(error.to_string()),
206 }
207 }
208 }
209 }
210 Err(format!("'{library_id}' could not be minted or found"))
211}
212
213#[derive(Clone, Debug, Default, PartialEq, Eq)]
215pub struct LibraryReport {
216 pub written: usize,
217 pub created: usize,
218 pub skipped: usize,
219 pub failed: Vec<(String, String)>,
223}
224
225pub async fn publish_library(
234 client: &PlmClient,
235 store: &dyn crate::store::ModelStore,
236 part_type: &str,
237 parts: &[(String, String)],
238 progress: &mut dyn FnMut(&LibraryReport) -> bool,
239) -> LibraryReport {
240 let mut report = LibraryReport::default();
241 for (library_id, document) in parts {
242 let outcome = match store.read(document) {
243 None => Err(format!("its document '{document}' is not in the store")),
244 Some(bytes) => {
245 let name = library_id.rsplit(':').next().unwrap_or(library_id);
246 publish_part(client, part_type, library_id, name, bytes.as_bytes()).await
247 }
248 };
249 match outcome {
250 Ok(Outcome::Written { created_part, .. }) => {
251 report.written += 1;
252 report.created += usize::from(created_part);
253 }
254 Ok(Outcome::Skipped { .. }) => report.skipped += 1,
255 Err(sentence) => report.failed.push((library_id.clone(), sentence)),
256 }
257 if !progress(&report) {
258 break;
259 }
260 }
261 report
262}
263