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::http::query_component;
30use crate::plm::client::PlmClient;
31use crate::plm::content_hash;
32use serde_json::Value;
33
34/// What the server says about the part a `library_id` already names (a row of
35/// `GET /api/parts?external_ref=`, P8).
36#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
37pub struct ImportedRow {
38    pub id: String,
39    #[serde(default)]
40    pub number: String,
41    #[serde(default)]
42    pub latest_revision_id: String,
43    /// `imported`, `authored` or `generated`; `None` for a part with no
44    /// revision.
45    #[serde(default)]
46    pub latest_origin: Option<String>,
47    /// `""` when the latest revision has no document yet.
48    #[serde(default)]
49    pub latest_content_hash: String,
50    /// The latest revision's lifecycle (`draft`, `released`, …).
51    #[serde(default)]
52    pub latest_state: String,
53}
54
55/// What to do with one KiCad part.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub enum Action {
58    /// No part names this `library_id`: mint one.
59    Mint,
60    /// The latest revision is an imported DRAFT — a run stopped between its
61    /// write and its release. Write it and release it.
62    Finish { part: String, revision: String },
63    /// The latest revision is imported, released and different: a new one.
64    Revise { part: String },
65    /// Nothing to send, and why.
66    Skip { part: String, reason: String },
67}
68
69/// Decide from the server's row (`None`: no part yet) and the hash of the
70/// document this import would write.
71pub fn decide(existing: Option<&ImportedRow>, hash: &str) -> Action {
72    let Some(row) = existing else {
73        return Action::Mint;
74    };
75    let skip = |reason: String| Action::Skip { part: row.id.clone(), reason };
76    match row.latest_origin.as_deref() {
77        Some("imported") => {}
78        Some(origin) => {
79            return skip(format!(
80                "{} was revised by hand (its latest revision is {origin}) — the library is not written over it",
81                row.number
82            ))
83        }
84        None => {
85            return Action::Finish { part: row.id.clone(), revision: row.latest_revision_id.clone() };
86        }
87    }
88    if row.latest_state == "draft" {
89        return Action::Finish { part: row.id.clone(), revision: row.latest_revision_id.clone() };
90    }
91    if row.latest_content_hash == hash {
92        return skip(format!("{} is unchanged", row.number));
93    }
94    Action::Revise { part: row.id.clone() }
95}
96
97/// What happened to one part.
98#[derive(Clone, Debug, PartialEq, Eq)]
99pub enum Outcome {
100    /// Written and released: the part and its new revision.
101    Written { part: String, revision: String, created_part: bool },
102    Skipped { part: String, reason: String },
103}
104
105fn body(value: Value) -> Option<Vec<u8>> {
106    Some(serde_json::to_vec(&value).unwrap_or_default())
107}
108
109async fn call(client: &PlmClient, method: &'static str, path: &str, payload: Option<Vec<u8>>) -> Result<Value, String> {
110    let response = client.call(method, path, payload).await.map_err(|error| error.to_string())?;
111    Ok(serde_json::from_slice(&response.body).unwrap_or(Value::Null))
112}
113
114/// The part `library_id` names in `part_type`, if any.
115pub async fn find(client: &PlmClient, part_type: &str, library_id: &str) -> Result<Option<ImportedRow>, String> {
116    let page = call(
117        client,
118        "GET",
119        &format!("/api/parts?limit=2&part_type={}&external_ref={}", query_component(part_type), query_component(library_id)),
120        None,
121    )
122    .await?;
123    let rows = page["parts"].as_array().cloned().unwrap_or_default();
124    Ok(rows.into_iter().next().and_then(|row| serde_json::from_value(row).ok()))
125}
126
127/// Write `document` into a revision and release it: checkout, write, check
128/// in, release — the server's order.
129async fn write_and_release(client: &PlmClient, part: &str, revision: &str, document: &[u8]) -> Result<(), String> {
130    let base = crate::plm::identity::revision_path(&part, &revision);
131    call(client, "POST", &format!("{base}/checkout"), body(serde_json::json!({ "client_id": "brep-app kicad" }))).await?;
132    let written = call(client, "PUT", &super::identity::doc_path(&super::identity::document_key(part, revision)), Some(document.to_vec())).await;
133    call(client, "POST", &format!("{base}/checkin"), body(serde_json::json!({}))).await?;
134    written?;
135    call(client, "POST", &format!("{base}/state"), body(serde_json::json!({ "to": "released" }))).await?;
136    Ok(())
137}
138
139/// Publish one KiCad part: find it by `library_id`, then mint, finish, revise
140/// or skip ([`decide`]). `document` is the exact bytes the KiCad import wrote
141/// for it. A mint refused because another import minted the same
142/// `library_id` meanwhile (`409`) re-reads and carries on with that part.
143pub async fn publish_part(
144    client: &PlmClient,
145    part_type: &str,
146    library_id: &str,
147    name: &str,
148    document: &[u8],
149) -> Result<Outcome, String> {
150    let hash = content_hash(document);
151    for attempt in 0..2 {
152        let existing = find(client, part_type, library_id).await?;
153        match decide(existing.as_ref(), &hash) {
154            Action::Skip { part, reason } => return Ok(Outcome::Skipped { part, reason }),
155            Action::Finish { part, revision } => {
156                write_and_release(client, &part, &revision, document).await?;
157                return Ok(Outcome::Written { part, revision, created_part: false });
158            }
159            Action::Revise { part } => {
160                let revision = call(
161                    client,
162                    "POST",
163                    &format!("{}/revisions", crate::plm::identity::part_path(&part)),
164                    body(serde_json::json!({ "origin": "imported" })),
165                )
166                .await?;
167                let revision = revision["id"].as_str().unwrap_or_default().to_string();
168                write_and_release(client, &part, &revision, document).await?;
169                return Ok(Outcome::Written { part, revision, created_part: false });
170            }
171            Action::Mint => {
172                let request = serde_json::json!({
173                    "part_type": part_type,
174                    "name": name,
175                    "external_ref": library_id,
176                    "origin": "imported",
177                    // A library import: the server links none of these parts
178                    // into the workspace (`brep_plm::workspace::link_on_create`).
179                    "bulk": true,
180                });
181                match client.call("POST", "/api/parts", body(request)).await {
182                    Ok(response) => {
183                        let created: Value = serde_json::from_slice(&response.body).unwrap_or(Value::Null);
184                        let part = created["id"].as_str().unwrap_or_default().to_string();
185                        let revision = created["revisions"][0]["id"].as_str().unwrap_or_default().to_string();
186                        write_and_release(client, &part, &revision, document).await?;
187                        return Ok(Outcome::Written { part, revision, created_part: true });
188                    }
189                    // Someone minted this library_id first: adopt theirs.
190                    Err(error) if error.status() == Some(409) && attempt == 0 => continue,
191                    Err(error) => return Err(error.to_string()),
192                }
193            }
194        }
195    }
196    Err(format!("'{library_id}' could not be minted or found"))
197}
198
199/// What a library pass did.
200#[derive(Clone, Debug, Default, PartialEq, Eq)]
201pub struct LibraryReport {
202    pub written: usize,
203    pub created: usize,
204    pub skipped: usize,
205    /// `(library_id, sentence)` for every part that could not be published.
206    /// Unlike the folder import, one refused part does not stop the pass: a
207    /// library is thousands of independent parts.
208    pub failed: Vec<(String, String)>,
209}
210
211/// Publish a KiCad library — the documents a sweep wrote, as
212/// `(library_id, document name)` pairs from its ledger — to the PLM, one part
213/// at a time, reading each document's bytes from `store` (exactly what the
214/// sweep wrote, so an unchanged part hashes equal and is skipped). The name
215/// a new part gets is the symbol's own (`NE555D` of `Timer:NE555D`).
216/// `progress` is called after every part with the report so far and
217/// answers whether to go on (`false` is Stop; the next pass skips what this
218/// one finished, by the server's own record).
219pub async fn publish_library(
220    client: &PlmClient,
221    store: &dyn crate::store::ModelStore,
222    part_type: &str,
223    parts: &[(String, String)],
224    progress: &mut dyn FnMut(&LibraryReport) -> bool,
225) -> LibraryReport {
226    let mut report = LibraryReport::default();
227    for (library_id, document) in parts {
228        let outcome = match store.read(document) {
229            None => Err(format!("its document '{document}' is not in the store")),
230            Some(bytes) => {
231                let name = library_id.rsplit(':').next().unwrap_or(library_id);
232                publish_part(client, part_type, library_id, name, bytes.as_bytes()).await
233            }
234        };
235        match outcome {
236            Ok(Outcome::Written { created_part, .. }) => {
237                report.written += 1;
238                report.created += usize::from(created_part);
239            }
240            Ok(Outcome::Skipped { .. }) => report.skipped += 1,
241            Err(sentence) => report.failed.push((library_id.clone(), sentence)),
242        }
243        if !progress(&report) {
244            break;
245        }
246    }
247    report
248}
249