use crate::document_class::DocumentClass;
use crate::plm::uses;
use crate::store::DocumentIdentity;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SourceDocument {
pub identity: String,
pub contents: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlannedPart {
pub identity: String,
pub class: DocumentClass,
pub name: String,
pub signature: String,
pub assembly: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AdoptionPlan {
pub parts: Vec<PlannedPart>,
pub refused: Vec<(String, String)>,
pub external: Vec<(String, String)>,
}
pub fn plan_adoption(
documents: &[SourceDocument],
canonical: &dyn Fn(&str) -> String,
) -> AdoptionPlan {
let mut plan = AdoptionPlan::default();
let held: BTreeSet<&str> = documents.iter().map(|d| d.identity.as_str()).collect();
let mut sorted: Vec<&SourceDocument> = documents.iter().collect();
sorted.sort_by(|a, b| a.identity.cmp(&b.identity));
for document in sorted {
let parsed: Value = match serde_json::from_str(&document.contents) {
Ok(parsed @ Value::Object(_)) => parsed,
Ok(_) => {
plan.refused
.push((document.identity.clone(), "not a model document (not a JSON object)".into()));
continue;
}
Err(error) => {
plan.refused
.push((document.identity.clone(), format!("does not parse: {error}")));
continue;
}
};
let mut keys = Vec::new();
source_keys(&parsed, &mut keys);
for key in keys {
if DocumentIdentity::parse_revision_key(&key).is_some() {
continue; }
if !held.contains(canonical(&key).as_str()) {
plan.external.push((document.identity.clone(), key));
}
}
let file = crate::store::file_name_of(&document.identity);
plan.parts.push(PlannedPart {
class: DocumentClass::of_name(&file).unwrap_or_default(),
name: crate::document_class::strip_class_extension(&file).to_string(),
signature: crate::panels::parts_library::document_signature(&document.contents),
assembly: !uses::occurrences(&parsed).is_empty(),
identity: document.identity.clone(),
});
}
plan.external.dedup();
plan
}
fn source_keys(document: &Value, out: &mut Vec<String>) {
let Some(library) = document.get("partsLibrary").and_then(Value::as_object) else {
return;
};
for entry in library.values() {
if let Some(key) = entry.get("sourceKey").and_then(Value::as_str) {
if !key.is_empty() {
out.push(key.to_string());
}
}
if let Some(nested) = entry.get("document") {
source_keys(nested, out);
}
}
}
pub fn rewrite_source_keys(
document: &mut Value,
to_key: &dyn Fn(&str) -> Option<String>,
) -> Vec<String> {
let mut unknown = Vec::new();
rewrite(document, to_key, &mut unknown);
unknown
}
fn rewrite(document: &mut Value, to_key: &dyn Fn(&str) -> Option<String>, unknown: &mut Vec<String>) {
let Some(library) = document.get_mut("partsLibrary").and_then(Value::as_object_mut) else {
return;
};
for entry in library.values_mut() {
let key = entry
.get("sourceKey")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if !key.is_empty() && DocumentIdentity::parse_revision_key(&key).is_none() {
match to_key(&key) {
Some(revision_key) => entry["sourceKey"] = Value::String(revision_key),
None => unknown.push(key),
}
}
if let Some(nested) = entry.get_mut("document") {
rewrite(nested, to_key, unknown);
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct LedgerEntry {
pub part: String,
pub revision: String,
pub signature: String,
#[serde(default)]
pub uploaded: bool,
#[serde(default)]
pub published: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Ledger {
pub entries: BTreeMap<String, LedgerEntry>,
}
impl Ledger {
pub fn revision_key(&self, identity: &str) -> Option<String> {
self.entries.get(identity).map(|entry| {
DocumentIdentity::Revision {
part: entry.part.clone(),
revision: entry.revision.clone(),
}
.key()
})
}
pub fn record_minted(&mut self, part: &PlannedPart, part_id: &str, revision_id: &str) {
self.entries.insert(
part.identity.clone(),
LedgerEntry {
part: part_id.into(),
revision: revision_id.into(),
signature: part.signature.clone(),
uploaded: false,
published: false,
},
);
}
pub fn record_uploaded(&mut self, identity: &str) {
if let Some(entry) = self.entries.get_mut(identity) {
entry.uploaded = true;
}
}
pub fn record_published(&mut self, identity: &str) {
if let Some(entry) = self.entries.get_mut(identity) {
entry.published = true;
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ImportLedgers {
pub servers: BTreeMap<String, BTreeMap<String, Ledger>>,
}
pub fn server_key(url: &str) -> String {
let url = url.trim().trim_end_matches('/');
match url.split_once("://") {
Some((scheme, rest)) => {
let (host, path) = rest.split_once('/').map_or((rest, ""), |(h, p)| (h, p));
let mut key = format!("{}://{}", scheme.to_ascii_lowercase(), host.to_ascii_lowercase());
if !path.is_empty() {
key.push('/');
key.push_str(path);
}
key
}
None => url.to_ascii_lowercase(),
}
}
impl ImportLedgers {
pub fn load(store: &dyn crate::store::ModelStore) -> Self {
store
.read(crate::store::PLM_IMPORT_KEY)
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
pub fn ledger(&self, server: &str, folder: &str) -> Ledger {
self.servers
.get(&server_key(server))
.and_then(|folders| folders.get(folder))
.cloned()
.unwrap_or_default()
}
pub fn save(
&mut self,
store: &dyn crate::store::ModelStore,
server: &str,
folder: &str,
ledger: &Ledger,
) -> Result<(), String> {
self.servers
.entry(server_key(server))
.or_default()
.insert(folder.to_string(), ledger.clone());
let text = serde_json::to_string(self).map_err(|error| error.to_string())?;
store.write(crate::store::PLM_IMPORT_KEY, &text)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Step {
Mint(PlannedPart),
Upload {
identity: String,
key: String,
document: String,
unknown: Vec<String>,
},
PublishUses {
identity: String,
key: String,
body: Result<Value, String>,
},
}
pub fn next_step(
plan: &AdoptionPlan,
documents: &[SourceDocument],
ledger: &Ledger,
canonical: &dyn Fn(&str) -> String,
) -> Option<Step> {
if let Some(part) = plan.parts.iter().find(|p| !ledger.entries.contains_key(&p.identity)) {
return Some(Step::Mint(part.clone()));
}
let contents = |identity: &str| {
documents
.iter()
.find(|d| d.identity == identity)
.map(|d| d.contents.as_str())
};
let to_key = |key: &str| ledger.revision_key(&canonical(key));
for part in &plan.parts {
let entry = &ledger.entries[&part.identity];
if entry.uploaded {
continue;
}
let mut document: Value = serde_json::from_str(contents(&part.identity)?).ok()?;
let unknown = rewrite_source_keys(&mut document, &to_key);
return Some(Step::Upload {
identity: part.identity.clone(),
key: ledger.revision_key(&part.identity)?,
document: document.to_string(),
unknown,
});
}
for part in plan.parts.iter().filter(|p| p.assembly) {
if ledger.entries[&part.identity].published {
continue;
}
let mut document: Value = serde_json::from_str(contents(&part.identity)?).ok()?;
rewrite_source_keys(&mut document, &to_key);
let resolve = |key: &str| {
DocumentIdentity::parse_revision_key(key)
.unwrap_or_else(|| DocumentIdentity::Path(key.to_string()))
};
return Some(Step::PublishUses {
identity: part.identity.clone(),
key: ledger.revision_key(&part.identity)?,
body: uses::uses_lines(&uses::occurrences(&document), &resolve).body(),
});
}
None
}
pub fn changed_since(plan: &AdoptionPlan, ledger: &Ledger) -> Vec<String> {
plan.parts
.iter()
.filter(|part| {
ledger
.entries
.get(&part.identity)
.is_some_and(|entry| entry.signature != part.signature)
})
.map(|part| part.identity.clone())
.collect()
}
pub fn walk_tree(store: &dyn crate::store::ModelStore, root: &str) -> Result<Vec<SourceDocument>, String> {
let before = store.browser_location();
let walked = walk(store, root);
let _ = store.browser_navigate(&before);
walked
}
fn walk(store: &dyn crate::store::ModelStore, root: &str) -> Result<Vec<SourceDocument>, String> {
let mut out = Vec::new();
let mut folders = vec![root.to_string()];
let mut seen = BTreeSet::new();
while let Some(folder) = folders.pop() {
if !seen.insert(folder.clone()) {
continue;
}
store.browser_navigate(&folder)?;
for entry in store.browser_entries(crate::store::MODEL_EXTENSIONS) {
if entry.is_dir {
folders.push(entry.identity);
} else {
let contents = store
.read(&entry.identity)
.ok_or_else(|| format!("'{}' could not be read", entry.identity))?;
out.push(SourceDocument { identity: entry.identity, contents });
}
}
}
out.sort_by(|a, b| a.identity.cmp(&b.identity));
Ok(out)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImportNumbering {
pub part_type: String,
pub number_from_name: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ImportReport {
pub minted: usize,
pub uploaded: usize,
pub published: usize,
pub refused: Vec<(String, String)>,
}
const CLIENT_ID: &str = "brep-app import";
async fn with_checkout(
client: &crate::plm::client::PlmClient,
key: &str,
write: impl std::future::Future<Output = Result<(), String>>,
) -> Result<(), String> {
let Some(DocumentIdentity::Revision { part, revision }) = DocumentIdentity::parse_revision_key(key) else {
return Err(format!("'{key}' is not a PLM revision"));
};
let base = crate::plm::identity::revision_path(&part, &revision);
let body = |value: Value| Some(serde_json::to_vec(&value).unwrap_or_default());
client
.call("POST", &format!("{base}/checkout"), body(serde_json::json!({ "client_id": CLIENT_ID })))
.await
.map_err(|error| error.to_string())?;
let written = write.await;
let checked_in = client
.call("POST", &format!("{base}/checkin"), body(serde_json::json!({})))
.await
.map_err(|error| error.to_string());
written.and(checked_in.map(|_| ()))
}
pub async fn load_into_revision(
client: &crate::plm::client::PlmClient,
key: &str,
contents: &str,
) -> Result<(), String> {
if serde_json::from_str::<Value>(contents).is_err() {
return Err("the file is not a model document (it does not parse as JSON)".into());
}
let path = crate::plm::identity::doc_path(key);
let bytes = contents.as_bytes().to_vec();
with_checkout(client, key, async {
client.call("PUT", &path, Some(bytes)).await.map(|_| ()).map_err(|e| e.to_string())
})
.await
}
pub async fn run_import(
client: &crate::plm::client::PlmClient,
plan: &AdoptionPlan,
documents: &[SourceDocument],
ledger: &mut Ledger,
canonical: &dyn Fn(&str) -> String,
numbering: &ImportNumbering,
persist: &mut dyn FnMut(&Ledger) -> bool,
) -> ImportReport {
let mut report = ImportReport::default();
while let Some(step) = next_step(plan, documents, ledger, canonical) {
let outcome: Result<(), String> = match &step {
Step::Mint(part) => {
let request = serde_json::json!({
"part_type": numbering.part_type,
"name": part.name,
"number": if numbering.number_from_name { part.name.clone() } else { String::new() },
"document_class": part.class.slug(),
"bulk": true,
});
match client
.call("POST", "/api/parts", Some(serde_json::to_vec(&request).unwrap_or_default()))
.await
{
Ok(response) => {
let created: Value = serde_json::from_slice(&response.body).unwrap_or_default();
let id = created["id"].as_str().unwrap_or_default();
let revision = created["revisions"][0]["id"].as_str().unwrap_or_default();
if id.is_empty() || revision.is_empty() {
Err("the PLM created the part but did not say its revision".to_string())
} else {
ledger.record_minted(part, id, revision);
report.minted += 1;
Ok(())
}
}
Err(error) => Err(error.to_string()),
}
}
Step::Upload { identity, key, document, .. } => {
let bytes = document.clone().into_bytes();
let path = crate::plm::identity::doc_path(key);
let written = with_checkout(client, key, async {
client.call("PUT", &path, Some(bytes)).await.map(|_| ()).map_err(|e| e.to_string())
})
.await;
written.map(|()| {
ledger.record_uploaded(identity);
report.uploaded += 1;
})
}
Step::PublishUses { identity, key, body } => match body {
Err(refusal) => Err(refusal.clone()),
Ok(body) => {
let Some(DocumentIdentity::Revision { part, revision }) = DocumentIdentity::parse_revision_key(key)
else {
return report;
};
let path = format!("{}/uses", crate::plm::identity::revision_path(&part, &revision));
let bytes = serde_json::to_vec(body).unwrap_or_default();
let written = with_checkout(client, key, async {
client.call("PUT", &path, Some(bytes)).await.map(|_| ()).map_err(|e| e.to_string())
})
.await;
written.map(|()| {
ledger.record_published(identity);
report.published += 1;
})
}
},
};
match outcome {
Ok(()) => {
if !persist(ledger) {
return report;
}
}
Err(sentence) => {
let identity = match &step {
Step::Mint(part) => part.identity.clone(),
Step::Upload { identity, .. } | Step::PublishUses { identity, .. } => identity.clone(),
};
report.refused.push((identity, sentence));
return report;
}
}
}
report
}