Skip to main content

brep_app/plm/
kicad.rs

1//! KiCad library parts become real PLM parts (S13's KiCad half, over P8).
2//!
3//! The KiCad import (`panels::kicad_bulk`) already turns each symbol into a
4//! part document, keyed by the symbol's `library_id` (`Timer:NE555D`). On a
5//! PLM those documents become Parts of their own part type:
6//!
7//! * **Deduped on `external_ref`** = the `library_id`. The server holds it
8//!   unique per part type (P8), so a re-import finds the part it made before,
9//!   and two imports of one symbol at once cannot make two parts. The second
10//!   one's `409` makes it re-read and adopt the first one's part.
11//! * **Minted as imported**, written, then **released**: a library part is
12//!   something people place, not something they draft.
13//! * **Revised on re-import only when it should be.** A part whose latest
14//!   revision is `imported` and whose document hash differs gets a new
15//!   `imported` revision. An unchanged one is skipped without downloading
16//!   anything: the hash is SHA-256 of the exact bytes this app would upload,
17//!   compared with the server's `latest_content_hash`, and those bytes are
18//!   deterministic (pinned by
19//!   `kicad_import::one_symbol_imports_to_the_same_bytes_every_time`). A part
20//!   someone has since revised BY HAND (latest revision `authored`) is left
21//!   alone. The import would otherwise overwrite their work.
22//!
23//! There is no separate resume ledger. The server is the record: every part
24//! is found by its `external_ref` in one cheap lookup, and a finished part
25//! compares equal and is skipped. A run stopped midway loses at most the part
26//! in flight. A part stopped between its write and its release is an
27//! `imported` draft, and the next run finishes it.
28
29use crate::plm::client::PlmClient;
30use serde_json::Value;
31
32/// Lowercase hex SHA-256 of `bytes` — the server's `content_hash` of a
33/// document written with exactly these bytes.
34pub 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/// What the server says about the part a `library_id` already names (a row of
40/// `GET /api/parts?external_ref=`, P8).
41#[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    /// `imported`, `authored` or `generated`; `None` for a part with no
49    /// revision.
50    #[serde(default)]
51    pub latest_origin: Option<String>,
52    /// `""` when the latest revision has no document yet.
53    #[serde(default)]
54    pub latest_content_hash: String,
55    /// The latest revision's lifecycle (`draft`, `released`, …).
56    #[serde(default)]
57    pub latest_state: String,
58}
59
60/// What to do with one KiCad part.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub enum Action {
63    /// No part names this `library_id`: mint one.
64    Mint,
65    /// The latest revision is an imported DRAFT — a run stopped between its
66    /// write and its release. Write it and release it.
67    Finish { part: String, revision: String },
68    /// The latest revision is imported, released and different: a new one.
69    Revise { part: String },
70    /// Nothing to send, and why.
71    Skip { part: String, reason: String },
72}
73
74/// Decide from the server's row (`None`: no part yet) and the hash of the
75/// document this import would write.
76pub 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/// What happened to one part.
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub enum Outcome {
105    /// Written and released: the part and its new revision.
106    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
128/// The part `library_id` names in `part_type`, if any.
129pub 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
141/// Write `document` into a revision and release it: checkout, write, check
142/// in, release — the server's order.
143async 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
153/// Publish one KiCad part: find it by `library_id`, then mint, finish, revise
154/// or skip ([`decide`]). `document` is the exact bytes the KiCad import wrote
155/// for it. A mint refused because another import minted the same
156/// `library_id` meanwhile (`409`) re-reads and carries on with that part.
157pub 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                    // A library import: the server links none of these parts
192                    // into the workspace (`brep_plm::workspace::link_on_create`).
193                    "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                    // Someone minted this library_id first: adopt theirs.
204                    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/// What a library pass did.
214#[derive(Clone, Debug, Default, PartialEq, Eq)]
215pub struct LibraryReport {
216    pub written: usize,
217    pub created: usize,
218    pub skipped: usize,
219    /// `(library_id, sentence)` for every part that could not be published.
220    /// Unlike the folder import, one refused part does not stop the pass: a
221    /// library is thousands of independent parts.
222    pub failed: Vec<(String, String)>,
223}
224
225/// Publish a KiCad library — the documents a sweep wrote, as
226/// `(library_id, document name)` pairs from its ledger — to the PLM, one part
227/// at a time, reading each document's bytes from `store` (exactly what the
228/// sweep wrote, so an unchanged part hashes equal and is skipped). The name
229/// a new part gets is the symbol's own (`NE555D` of `Timer:NE555D`).
230/// `progress` is called after every part with the report so far and
231/// answers whether to go on (`false` is Stop; the next pass skips what this
232/// one finished, by the server's own record).
233pub 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