1use super::Documents;
59use crate::panels::parts_library::{document_signature, WriteOutcome};
60use crate::store::{ModelStore, ReadNow};
61use brep_render::brep_kernel::stable_json_hash;
62use brep_render::engine_state::{EngineState, NoticeSeverity};
63use serde_json::Value;
64
65#[derive(Debug, Clone, PartialEq)]
67pub struct OwnedWrite {
68 pub key: String,
70 pub names: Vec<String>,
72 pub depth: usize,
74 pub text: String,
76 pub synced: String,
79 pub variants: Vec<(Vec<String>, String)>,
82}
83
84pub type OwnedCacheKey = ((u64, (u64, u64), u64), Vec<String>);
87
88pub type SyncedCache = ((u64, u64), std::collections::HashMap<String, String>);
91
92#[derive(Debug, Clone, Default, PartialEq)]
95pub struct OwnedChanges {
96 pub writes: Vec<OwnedWrite>,
97}
98
99#[derive(Debug, Clone, PartialEq)]
101pub struct Promotion {
102 pub names: Vec<String>,
104 pub old_key: String,
106 pub new_key: String,
108}
109
110impl Promotion {
111 pub fn recreates(&self) -> bool {
113 !self.old_key.is_empty() && self.old_key == self.new_key
114 }
115}
116
117#[derive(Debug, Clone, PartialEq)]
119pub enum Issue {
120 Overwrite,
123 CheckOut { part: String, revision: String, newer: bool },
128 SaveAsNew { why: String, new_key: String },
130 Conflict,
133 NewRevision { part: String, from: String },
138 Blocked(String),
140}
141
142#[derive(Debug, Clone, PartialEq)]
144pub struct Planned {
145 pub write: OwnedWrite,
146 pub issue: Option<Issue>,
147 pub proceed: bool,
149 pub choice: usize,
151 pub edited_last: Option<usize>,
154}
155
156pub fn shown(key: &str, names: &[String]) -> String {
160 match (crate::plm::bom::revision_of_document(key), names.last().filter(|name| !name.is_empty())) {
161 (Some((part, revision)), Some(name)) => format!("{name} ({part} rev {revision})"),
162 (Some((part, revision)), None) => format!("{part} rev {revision}"),
163 (None, _) => key.to_string(),
164 }
165}
166
167pub fn saved_notice(part: &str, key: &str) -> String {
170 if crate::plm::bom::revision_of_document(key).is_some() {
171 format!("Saved {}", shown(key, &[part.to_string()]))
172 } else {
173 format!("Saved '{part}' to '{key}'")
174 }
175}
176
177impl Planned {
178 pub fn question(&self) -> String {
180 let key = &shown(&self.write.key, &self.write.names);
181 match &self.issue {
182 None => format!("Save {key}"),
183 Some(Issue::Overwrite) if crate::plm::bom::revision_of_document(&self.write.key).is_some() => format!(
184 "{key} was saved again on the PLM since this assembly last wrote it — overwrite it with the copy here (untick to keep the PLM's version; the change stays in this assembly)"
185 ),
186 Some(Issue::Overwrite) => format!("{key} changed on disk since this assembly loaded it — overwrite it"),
187 Some(Issue::CheckOut { newer: true, .. }) => format!(
188 "{key} was saved again on the PLM since this assembly read it, and is not checked out to you — check it out and overwrite it with the copy here (untick to keep the PLM's version; the change stays in this assembly)"
189 ),
190 Some(Issue::CheckOut { .. }) => format!("{key} is not checked out to you — check it out and save it"),
191 Some(Issue::SaveAsNew { why, new_key }) => format!("{key} {why} — save the change as a new file {new_key}"),
192 Some(Issue::Conflict) => {
193 let places: Vec<String> = self.write.variants.iter().map(|(names, _)| names.join(" > ")).collect();
194 let newest = self
195 .edited_last
196 .and_then(|i| places.get(i))
197 .map(|place| format!("; the copy in {place} was edited last"))
198 .unwrap_or_default();
199 format!("{key} was changed differently in {} ({}){newest} — keep one", places.len(), places.join(", "))
200 }
201 Some(Issue::NewRevision { from, .. }) => format!("{key} is {from} — make a new revision of it and save the change there"),
202 Some(Issue::Blocked(why)) => format!("{key}: {why} — not saved, kept in the assembly"),
203 }
204 }
205
206 pub fn asks(&self) -> bool {
208 matches!(&self.issue, Some(issue) if !matches!(issue, Issue::Blocked(_)))
209 }
210}
211
212#[derive(Debug, Clone, Default, PartialEq)]
214pub struct OwnedSavePlan {
215 pub doc_id: u64,
217 pub rows: Vec<Planned>,
218 pub promotions: Vec<Promotion>,
220 pub saving_as: String,
223}
224
225impl OwnedSavePlan {
226 pub fn needs_asking(&self) -> bool {
228 self.rows.iter().any(Planned::asks)
229 }
230
231 pub fn to_json(&self) -> Value {
232 serde_json::json!({
233 "rows": self.rows.iter().map(|row| serde_json::json!({
234 "key": row.write.key,
235 "issue": match &row.issue {
236 None => "none",
237 Some(Issue::Overwrite) => "overwrite",
238 Some(Issue::CheckOut { .. }) => "checkout",
239 Some(Issue::SaveAsNew { .. }) => "saveasnew",
240 Some(Issue::Conflict) => "conflict",
241 Some(Issue::NewRevision { .. }) => "newrevision",
242 Some(Issue::Blocked(_)) => "blocked",
243 },
244 "question": row.question(),
245 "proceed": row.proceed,
246 "choice": row.choice,
247 "editedLast": row.edited_last,
248 "variants": row.write.variants.iter().map(|(names, _)| names.join(" > ")).collect::<Vec<_>>(),
249 })).collect::<Vec<_>>(),
250 "promotions": self.promotions.iter().map(|p| serde_json::json!({
251 "names": p.names,
252 "oldKey": p.old_key,
253 "newKey": p.new_key,
254 })).collect::<Vec<_>>(),
255 })
256 }
257}
258
259#[derive(Debug, Clone, PartialEq)]
261pub struct CheckoutRequest {
262 pub key: String,
263 pub part: String,
264 pub revision: String,
265 pub text: String,
266 pub shown: String,
268 pub new_revision: bool,
272}
273
274#[derive(Debug, Clone, Default, PartialEq)]
276pub struct OwnedSaveReport {
277 pub written: Vec<String>,
278 pub skipped: Vec<(String, String)>,
280 pub created: Vec<String>,
282 pub recreated: Vec<String>,
284 pub failed_promotions: Vec<String>,
287 pub checking_out: Vec<String>,
289 pub revising: Vec<String>,
292 pub shown: std::collections::BTreeMap<String, String>,
295}
296
297impl OwnedSaveReport {
298 pub fn to_json(&self) -> Value {
299 serde_json::json!({
300 "written": self.written,
301 "skipped": self.skipped.iter().map(|(key, reason)| serde_json::json!({"key": key, "reason": reason})).collect::<Vec<_>>(),
302 "created": self.created,
303 "recreated": self.recreated,
304 "failedPromotions": self.failed_promotions,
305 "checkingOut": self.checking_out,
306 "revising": self.revising,
307 })
308 }
309
310 pub fn summary(&self) -> String {
312 let named = |keys: &[String]| keys.iter().map(|key| self.shown.get(key).unwrap_or(key).as_str()).collect::<Vec<_>>().join(", ");
313 let mut parts = Vec::new();
314 if !self.written.is_empty() {
315 parts.push(format!("also saved {}", named(&self.written)));
316 }
317 if !self.revising.is_empty() {
318 parts.push(format!("making new revisions of {}", named(&self.revising)));
319 }
320 if !self.checking_out.is_empty() {
321 parts.push(format!("checking out and saving {}", named(&self.checking_out)));
322 }
323 if !self.created.is_empty() {
324 parts.push(format!("new part files {}", self.created.join(", ")));
325 }
326 if !self.recreated.is_empty() {
327 parts.push(format!("re-created {} (the file was missing)", self.recreated.join(", ")));
328 }
329 if !self.skipped.is_empty() {
330 let names: Vec<String> = self.skipped.iter().map(|(key, why)| format!("{} ({why})", self.shown.get(key).unwrap_or(key))).collect();
331 parts.push(format!("not saved: {} — the change is kept in the assembly", names.join("; ")));
332 }
333 parts.join("; ")
334 }
335}
336
337pub(super) fn writable(key: &str) -> bool {
340 !key.is_empty() && !key.starts_with('@')
341}
342
343pub(super) fn text_hash(text: &str) -> u64 {
344 serde_json::from_str::<Value>(text).map_or(0, |value| source_hash(&value))
345}
346
347pub(crate) fn source_hash(document: &Value) -> u64 {
353 let Some(object) = document.as_object() else {
354 return stable_json_hash(document);
355 };
356 let mut bare = object.clone();
357 bare.remove("thumbnail");
358 bare.remove("workbench");
359 bare.remove(INSERTED_COPIES_BLOCK);
360 let mut bare = Value::Object(bare);
361 super::without_run_record(&mut bare);
362 stable_json_hash(&bare)
363}
364
365pub fn fill_inserted(baseline: &mut Value, current: &Value, libraries: &[&Value], store: Option<&dyn ModelStore>) {
375 let block = current[INSERTED_COPIES_BLOCK].clone();
376 fill_inserted_at(baseline, current, &mut Vec::new(), libraries, &block, store);
377}
378
379fn fill_inserted_at(
380 baseline: &mut Value,
381 current: &Value,
382 names: &mut Vec<String>,
383 libraries: &[&Value],
384 block: &Value,
385 store: Option<&dyn ModelStore>,
386) {
387 let Some(entries) = current["partsLibrary"].as_object() else {
388 return;
389 };
390 for (name, entry) in entries {
391 names.push(name.clone());
392 if !baseline["partsLibrary"][name].is_object() && baseline.is_object() {
393 let mut inserted = None;
394 for library in libraries {
395 match library_entry(library, names) {
396 Some(state) => inserted = Some(state),
397 None => break,
398 }
399 }
400 let noted = (names.len() == 1).then(|| noted_baseline(entry, block, store)).flatten();
401 baseline["partsLibrary"][name] = noted.unwrap_or_else(|| inserted.unwrap_or(entry).clone());
402 }
403 if let Some(base) = baseline.get_mut("partsLibrary").and_then(|l| l.get_mut(name)) {
404 if let Some(document) = base.get_mut("document").filter(|d| d.is_object()) {
405 fill_inserted_at(document, &entry["document"], names, libraries, block, store);
406 }
407 }
408 names.pop();
409 }
410}
411
412fn library_entry<'a>(library: &'a Value, names: &[String]) -> Option<&'a Value> {
414 let (first, rest) = names.split_first()?;
415 let entry = &library[first];
416 if rest.is_empty() {
417 return entry.is_object().then_some(entry);
418 }
419 super::work_part::entry_at(&entry["document"], rest)
420}
421
422pub fn owned_changes(baseline: &Value, current: &Value) -> OwnedChanges {
426 let mut found: Vec<OwnedWrite> = Vec::new();
427 walk(Some(baseline), current, &mut Vec::new(), &mut found);
428 found.sort_by(|a, b| b.depth.cmp(&a.depth));
430 let mut writes: Vec<OwnedWrite> = Vec::new();
431 for item in found {
432 match writes.iter_mut().find(|w| w.key == item.key) {
433 Some(existing) => {
434 if !existing.variants.iter().any(|(_, text)| text_hash(text) == text_hash(&item.text)) {
435 existing.variants.push((item.names, item.text));
436 }
437 }
438 None => writes.push(item),
439 }
440 }
441 OwnedChanges { writes }
442}
443
444fn walk(
445 baseline: Option<&Value>,
446 current: &Value,
447 names: &mut Vec<String>,
448 found: &mut Vec<OwnedWrite>,
449) {
450 let Some(entries) = current["partsLibrary"].as_object() else {
451 return;
452 };
453 for (name, entry) in entries {
454 let base = baseline
455 .map(|b| &b["partsLibrary"][name])
456 .filter(|b| b.is_object());
457 if base.is_some_and(|base| base["document"] == entry["document"]) {
462 continue;
463 }
464 names.push(name.clone());
465 let before = found.len();
466 walk(base.map(|b| &b["document"]), &entry["document"], names, found);
467 if let Some(base) = base {
468 let document = &entry["document"];
469 let below = found.len() > before;
473 if document.is_object() && (below || source_hash(document) != source_hash(&base["document"])) {
474 let key = entry["sourceKey"].as_str().unwrap_or("");
475 if writable(key) {
476 let text = part_text(document);
477 found.push(OwnedWrite {
478 key: key.to_string(),
479 names: names.clone(),
480 depth: names.len(),
481 variants: vec![(names.clone(), text.clone())],
482 text,
483 synced: base["sourceSignature"].as_str().unwrap_or("").to_string(),
484 });
485 }
486 }
487 }
488 names.pop();
489 }
490}
491
492pub fn new_file_beside(store: &dyn ModelStore, key: &str) -> String {
496 let file = crate::store::file_name_of(key);
497 let (stem, extension) = match file.rfind('.') {
498 Some(dot) if dot > 0 => (&file[..dot], &file[dot..]),
499 _ => (file.as_str(), ".nbrep"),
500 };
501 (1..)
502 .map(|n| {
503 let name = if n == 1 { format!("{stem}-edited{extension}") } else { format!("{stem}-edited-{n}{extension}") };
504 store.sibling_of(key, &name).unwrap_or(name)
505 })
506 .find(|candidate| store.read(candidate).is_none())
507 .unwrap_or_default()
508}
509
510pub fn new_part_key(store: &dyn ModelStore, saving_as: &str, part: &str, taken: &mut Vec<String>) -> String {
515 let stem = crate::panels::file::sanitize_file_stem(part);
516 let assembly = crate::panels::file::sanitize_file_stem(&crate::store::model_display_name(saving_as));
517 let key = (0..)
518 .map(|n| {
519 let name = match n {
520 0 => format!("{stem}.nbrep"),
521 1 => format!("{assembly}-{stem}.nbrep"),
522 n => format!("{assembly}-{stem}-{n}.nbrep"),
523 };
524 store.sibling_of(saving_as, &name).unwrap_or(name)
525 })
526 .find(|candidate| !taken.contains(candidate) && store.residency(candidate) == crate::store::Residency::Absent)
527 .unwrap_or_default();
528 taken.push(key.clone());
529 key
530}
531
532pub fn plan_promotions(store: &dyn ModelStore, current: &Value, saving_as: &str) -> Vec<Promotion> {
536 let mut found = Vec::new();
537 collect_promotions(store, current, &mut Vec::new(), &mut found);
538 found.sort_by(|a: &(Vec<String>, String, u64), b| b.0.len().cmp(&a.0.len()));
539 let mut taken = Vec::new();
540 let mut by_content: Vec<(u64, String)> = Vec::new();
541 let mut promotions: Vec<Promotion> = Vec::new();
542 for (names, old_key, hash) in found {
543 let new_key = if !old_key.is_empty() {
544 if promotions.iter().any(|p| p.old_key == old_key) {
545 continue; }
547 taken.push(old_key.clone());
548 old_key.clone()
549 } else if let Some((_, key)) = by_content.iter().find(|(h, _)| *h == hash) {
550 key.clone()
551 } else {
552 let key = new_part_key(store, saving_as, names.last().map_or("part", String::as_str), &mut taken);
553 by_content.push((hash, key.clone()));
554 key
555 };
556 promotions.push(Promotion { names, old_key, new_key });
557 }
558 promotions
559}
560
561fn collect_promotions(store: &dyn ModelStore, document: &Value, names: &mut Vec<String>, found: &mut Vec<(Vec<String>, String, u64)>) {
562 let Some(entries) = document["partsLibrary"].as_object() else {
563 return;
564 };
565 for (name, entry) in entries {
566 names.push(name.clone());
567 collect_promotions(store, &entry["document"], names, found);
568 let key = entry["sourceKey"].as_str().unwrap_or("");
569 let document = &entry["document"];
570 if document.is_object() {
571 if key.is_empty() {
572 found.push((names.clone(), String::new(), source_hash(document)));
573 } else if writable(key) && store.residency(key) == crate::store::Residency::Absent {
574 found.push((names.clone(), key.to_string(), 0));
575 }
576 }
577 names.pop();
578 }
579}
580
581fn edited_last(write: &OwnedWrite, libraries: &[&Value]) -> Option<usize> {
587 let mut best: Option<(usize, usize)> = None; for (index, (names, text)) in write.variants.iter().enumerate() {
589 let now = text_hash(text);
590 let age = libraries.iter().position(|library| {
591 library_entry(library, names).is_none_or(|entry| source_hash(&entry["document"]) != now)
592 });
593 if let Some(age) = age {
594 if best.is_none_or(|(best_age, _)| age < best_age) {
595 best = Some((age, index));
596 }
597 }
598 }
599 best.map(|(_, index)| index)
600}
601
602fn copies_of(document: &Value, key: &str) -> Vec<Vec<String>> {
604 fn walk(document: &Value, key: &str, names: &mut Vec<String>, out: &mut Vec<Vec<String>>) {
605 let Some(entries) = document["partsLibrary"].as_object() else { return };
606 for (name, entry) in entries {
607 names.push(name.clone());
608 if entry["sourceKey"].as_str() == Some(key) {
609 out.push(names.clone());
610 }
611 walk(&entry["document"], key, names, out);
612 names.pop();
613 }
614 }
615 let mut out = Vec::new();
616 walk(document, key, &mut Vec::new(), &mut out);
617 out
618}
619
620fn set_entry_source(document: &mut Value, names: &[String], key: &str, signature: &str) -> bool {
623 let Some((last, parents)) = names.split_last() else {
624 return false;
625 };
626 let mut owner = document;
627 for name in parents {
628 let Some(child) = owner.get_mut("partsLibrary").and_then(|l| l.get_mut(name)).and_then(|e| e.get_mut("document")) else {
629 return false;
630 };
631 owner = child;
632 }
633 let Some(entry) = owner.get_mut("partsLibrary").and_then(|l| l.get_mut(last)).filter(|e| e.is_object()) else {
634 return false;
635 };
636 let before = (entry["sourceKey"].clone(), entry["sourceSignature"].clone());
637 entry["sourceKey"] = Value::String(key.to_string());
638 entry["sourceSignature"] = Value::String(signature.to_string());
639 before != (entry["sourceKey"].clone(), entry["sourceSignature"].clone())
640}
641
642pub const VIEW_ONLY: &str = "your PLM account can view it but not save to it (saving needs the author group)";
647
648fn issue_of(store: &dyn ModelStore, write: &OwnedWrite) -> (Option<Issue>, bool) {
649 if write.variants.len() > 1 {
650 return (Some(Issue::Conflict), true);
651 }
652 if let Some(client) = store.plm_client() {
653 if let Some((part, revision)) = crate::plm::bom::revision_of_document(&write.key) {
654 let viewer = client.can_author() == Some(false);
657 match store.plm_revision(&write.key) {
658 Some(entry) if entry.editable && entry.locked_by_me => {}
659 Some(_) if viewer => return (Some(Issue::Blocked(VIEW_ONLY.into())), true),
660 Some(entry) if !entry.editable => {
661 let from = format!("revision {} ({})", entry.revision_label, entry.lifecycle);
664 return (Some(Issue::NewRevision { part, from }), true);
665 }
666 Some(entry) if entry.locked_by.is_some() => {
667 let why = format!("checked out by {}", entry.locked_by.unwrap_or_default());
668 return (Some(Issue::Blocked(why)), true);
669 }
670 _ => {
671 let newer = match crate::store::read_now(store, &write.key) {
675 ReadNow::Ready(current) => {
676 text_hash(¤t) != text_hash(&write.text)
677 && document_signature(¤t) != write.synced
678 && !store.write_failed(&write.key)
679 }
680 ReadNow::Loading => return (Some(Issue::Blocked("still loading".into())), true),
681 ReadNow::Absent => false,
682 };
683 return (Some(Issue::CheckOut { part, revision, newer }), true);
684 }
685 }
686 }
687 }
688 let current = match crate::store::read_now(store, &write.key) {
689 ReadNow::Ready(text) => text,
690 ReadNow::Loading => return (Some(Issue::Blocked("still loading".into())), true),
691 ReadNow::Absent => {
692 let new_key = new_file_beside(store, &write.key);
693 return (Some(Issue::SaveAsNew { why: "is no longer in storage".into(), new_key }), true);
694 }
695 };
696 if let Some(source) = serde_json::from_str::<Value>(¤t)
697 .ok()
698 .as_ref()
699 .and_then(crate::family_table::family_source)
700 {
701 let new_key = new_file_beside(store, &write.key);
702 let why = format!("is member '{}' of the family '{}'", source.part_number, source.family);
703 return (Some(Issue::SaveAsNew { why, new_key }), true);
704 }
705 if text_hash(¤t) == text_hash(&write.text) {
706 return (None, false);
707 }
708 let changed_elsewhere = document_signature(¤t) != write.synced && !store.write_failed(&write.key);
712 (changed_elsewhere.then_some(Issue::Overwrite), true)
713}
714
715pub const UNSAVED_EDITS_BLOCK: &str = "unsavedEdits";
724
725pub const INSERTED_COPIES_BLOCK: &str = "insertedCopies";
734
735pub(crate) fn note_inserted_copies(doc: &mut super::Document) {
740 let seen = (doc.edit_key(), doc.baseline_generation);
741 if doc.inserted_noted == Some(seen) {
742 return;
743 }
744 doc.inserted_noted = Some(seen);
745 let saved: std::collections::HashSet<String> = doc.saved_sources().into_values().map(|(key, _)| key).collect();
746 let old = doc.engine.history.document_block(INSERTED_COPIES_BLOCK).cloned();
747 let mut block = serde_json::Map::new();
748 if let Some(entries) = doc.engine.history.parts_library().as_object() {
749 for entry in entries.values() {
750 let Some(key) = entry["sourceKey"].as_str().filter(|key| writable(key)) else { continue };
751 if saved.contains(key) || block.contains_key(key) {
752 continue;
753 }
754 let noted = old.as_ref().and_then(|old| old.get(key)).cloned().or_else(|| {
755 entry["sourceSignature"].as_str().filter(|s| !s.is_empty()).map(|s| Value::String(s.to_string()))
756 });
757 if let Some(signature) = noted {
758 block.insert(key.to_string(), signature);
759 }
760 }
761 }
762 let block = (!block.is_empty()).then_some(Value::Object(block));
763 if block != old {
764 doc.engine.history.set_document_block_no_undo(INSERTED_COPIES_BLOCK, block);
765 doc.inserted_noted = Some((doc.edit_key(), doc.baseline_generation));
766 }
767}
768
769fn noted_baseline(entry: &Value, block: &Value, store: Option<&dyn ModelStore>) -> Option<Value> {
775 let key = entry["sourceKey"].as_str()?;
776 let noted = block.get(key)?.as_str()?;
777 if let Some(ReadNow::Ready(text)) = store.map(|store| crate::store::read_now(store, key)) {
778 if document_signature(&text) == noted {
779 let mut base = entry.clone();
780 base["document"] = serde_json::from_str(&text).ok()?;
781 base["sourceSignature"] = Value::String(noted.to_string());
782 return Some(base);
783 }
784 }
785 if document_signature(&entry["document"].to_string()) == noted {
786 return Some(entry.clone());
787 }
788 let mut base = entry.clone();
789 base["document"] = Value::Null;
790 base["sourceSignature"] = Value::String(noted.to_string());
791 Some(base)
792}
793
794fn part_text(document: &Value) -> String {
797 match document.as_object() {
798 Some(object) if object.contains_key(INSERTED_COPIES_BLOCK) => {
799 let mut object = object.clone();
800 object.shift_remove(INSERTED_COPIES_BLOCK);
801 Value::Object(object).to_string()
802 }
803 _ => document.to_string(),
804 }
805}
806
807pub fn unsaved_edits(document: &Value) -> std::collections::BTreeMap<String, String> {
809 document[UNSAVED_EDITS_BLOCK]
810 .as_object()
811 .map(|block| block.iter().map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string())).collect())
812 .unwrap_or_default()
813}
814
815pub(crate) fn mark_unsaved(document: &mut Value, key: &str, why: &str) -> bool {
818 let Some(object) = document.as_object_mut() else { return false };
819 let block = object.entry(UNSAVED_EDITS_BLOCK).or_insert_with(|| Value::Object(Default::default()));
820 if !block.is_object() {
821 *block = Value::Object(Default::default());
822 }
823 if block[key].as_str() == Some(why) {
824 return false;
825 }
826 block[key] = Value::String(why.to_string());
827 true
828}
829
830pub(crate) fn clear_unsaved(document: &mut Value, key: &str) -> bool {
832 let Some(block) = document.get_mut(UNSAVED_EDITS_BLOCK).and_then(Value::as_object_mut) else { return false };
833 let removed = block.remove(key).is_some();
834 if block.is_empty() {
835 document.as_object_mut().map(|o| o.remove(UNSAVED_EDITS_BLOCK));
836 }
837 removed
838}
839
840pub(crate) fn first_copy(document: &Value, key: &str, names: &mut Vec<String>) -> Option<(Vec<String>, Value)> {
842 let entries = document["partsLibrary"].as_object()?;
843 for (name, entry) in entries {
844 names.push(name.clone());
845 if entry["sourceKey"].as_str() == Some(key) {
846 return Some((names.clone(), entry.clone()));
847 }
848 if let Some(found) = first_copy(&entry["document"], key, names) {
849 return Some(found);
850 }
851 names.pop();
852 }
853 None
854}
855
856fn store_unsaved_block(engine: &mut EngineState, document: &Value) {
859 let block = document.get(UNSAVED_EDITS_BLOCK).cloned();
860 if engine.history.document_block(UNSAVED_EDITS_BLOCK) != block.as_ref() {
861 engine.history.set_document_block_no_undo(UNSAVED_EDITS_BLOCK, block);
862 }
863}
864
865fn edit_unsaved_block(engine: &mut EngineState, edit: impl FnOnce(&mut Value) -> bool) -> bool {
870 let mut marks = serde_json::json!({});
871 if let Some(block) = engine.history.document_block(UNSAVED_EDITS_BLOCK) {
872 marks[UNSAVED_EDITS_BLOCK] = block.clone();
873 }
874 if edit(&mut marks) {
875 store_unsaved_block(engine, &marks);
876 return true;
877 }
878 false
879}
880
881fn stamp_copies(document: &mut Value, key: &str, signature: &str, retarget: Option<(&str, &Value)>) -> bool {
885 let Some(entries) = document.get_mut("partsLibrary").and_then(Value::as_object_mut) else {
888 return false;
889 };
890 let mut changed = false;
891 for entry in entries.values_mut() {
892 if let Some(child) = entry.get_mut("document") {
893 changed |= stamp_copies(child, key, signature, retarget);
894 }
895 if entry["sourceKey"].as_str() == Some(key) {
896 if let Some((new_key, body)) = retarget {
897 entry["sourceKey"] = Value::String(new_key.to_string());
898 entry["document"] = body.clone();
899 changed = true;
900 }
901 if entry["sourceSignature"].as_str() != Some(signature) {
902 entry["sourceSignature"] = Value::String(signature.to_string());
903 changed = true;
904 }
905 }
906 }
907 changed
908}
909
910pub fn retarget_text(text: &str, done: &[(String, String, String)]) -> String {
914 let Ok(mut document) = serde_json::from_str::<Value>(text) else { return text.to_string() };
915 let mut changed = false;
916 for (old, new, body) in done {
917 if let Ok(body_value) = serde_json::from_str::<Value>(body) {
918 changed |= stamp_copies(&mut document, old, &document_signature(body), Some((new, &body_value)));
919 }
920 }
921 if changed { document.to_string() } else { text.to_string() }
922}
923
924impl Documents {
925 fn baseline_and_current(&self, index: usize, store: Option<&dyn ModelStore>) -> Option<(Value, Value)> {
930 let doc = self.open.get(index)?;
931 let mut baseline = match doc.saved_signature.as_deref() {
932 Some(saved) => serde_json::from_str(saved).ok()?,
933 None => store
934 .zip(doc.name())
935 .and_then(|(store, name)| match crate::store::read_now(store, name) {
936 ReadNow::Ready(text) => serde_json::from_str(&text).ok(),
937 _ => None,
938 })
939 .unwrap_or_else(|| serde_json::json!({})),
940 };
941 let current = doc.engine.history_request_value();
942 let libraries: Vec<&Value> = doc.engine.history.undo_parts_libraries().collect();
943 fill_inserted(&mut baseline, ¤t, &libraries, store);
944 Some((baseline, current))
945 }
946
947 pub fn owned_changes(&self) -> OwnedChanges {
949 self.owned_changes_at(self.active, None)
950 }
951
952 fn owned_changes_at(&self, index: usize, store: Option<&dyn ModelStore>) -> OwnedChanges {
953 self.baseline_and_current(index, store)
954 .map(|(baseline, current)| owned_changes(&baseline, ¤t))
955 .unwrap_or_default()
956 }
957
958 pub fn synced_signatures(&self) -> std::collections::HashMap<String, String> {
964 let doc = &self.open[self.active];
965 let key = (doc.id(), doc.baseline_generation);
966 let mut map = self.baseline_signatures(key);
967 let library = doc.engine.history.parts_library();
968 let mut learned = self.learned_signatures.borrow_mut();
969 if let Some(entries) = library.as_object() {
970 for (name, entry) in entries {
971 if map.contains_key(name) {
972 continue;
973 }
974 let Some(signature) = entry["sourceSignature"].as_str() else { continue };
975 let first = learned.entry((key, name.clone())).or_insert_with(|| signature.to_string());
976 map.insert(name.clone(), first.clone());
977 }
978 }
979 for (chain, entry) in nested_entries(library) {
982 if map.contains_key(&chain) {
983 continue;
984 }
985 let Some(signature) = entry["sourceSignature"].as_str() else { continue };
986 let first = learned.entry((key, chain.clone())).or_insert_with(|| signature.to_string());
987 map.insert(chain, first.clone());
988 }
989 map
990 }
991
992 fn baseline_signatures(&self, key: (u64, u64)) -> std::collections::HashMap<String, String> {
993 if let Some((cached, map)) = self.synced_cache.borrow().as_ref() {
994 if *cached == key {
995 return map.clone();
996 }
997 }
998 let doc = &self.open[self.active];
999 let mut map: std::collections::HashMap<String, String> =
1000 doc.saved_sources().into_iter().filter(|(_, (_, signature))| !signature.is_empty()).map(|(name, (_, signature))| (name, signature)).collect();
1001 if let Some(saved) = doc.saved_document() {
1004 for (chain, entry) in nested_entries(&saved["partsLibrary"]) {
1005 if let Some(signature) = entry["sourceSignature"].as_str().filter(|s| !s.is_empty()) {
1006 map.insert(chain, signature.to_string());
1007 }
1008 }
1009 }
1010 *self.synced_cache.borrow_mut() = Some((key, map.clone()));
1011 map
1012 }
1013
1014 pub fn owned_modified_keys(&self) -> Vec<String> {
1016 self.owned_modified_keys_of(self.active_id())
1017 }
1018
1019 pub fn owned_modified_keys_of(&self, id: u64) -> Vec<String> {
1022 let Some(index) = self.open.iter().position(|d| d.id() == id) else {
1023 return Vec::new();
1024 };
1025 let key = (id, self.open[index].edit_key(), self.open[index].baseline_generation);
1026 if let Some((cached, keys)) = self.owned_cache.borrow().as_ref() {
1027 if *cached == key {
1028 return keys.clone();
1029 }
1030 }
1031 let keys: Vec<String> = self.owned_changes_at(index, None).writes.into_iter().map(|w| w.key).collect();
1032 *self.owned_cache.borrow_mut() = Some((key, keys.clone()));
1033 keys
1034 }
1035
1036 pub fn plan_owned_save(&self, store: &dyn ModelStore, saving_as: &str) -> OwnedSavePlan {
1040 let changes = self.owned_changes_at(self.active, Some(store));
1041 let current: Value = self.open[self.active].engine.history_request_value();
1042 let promotions = plan_promotions(store, ¤t, saving_as);
1043 let mut rows = Vec::new();
1044 for write in changes.writes {
1045 if (self.canon)(&write.key) == (self.canon)(saving_as) {
1046 continue; }
1048 if promotions.iter().any(|p| p.old_key == write.key) {
1049 continue; }
1051 let (issue, _needed) = issue_of(store, &write);
1054 let edited_last = (issue == Some(Issue::Conflict))
1055 .then(|| edited_last(&write, &self.open[self.active].engine.history.undo_parts_libraries().collect::<Vec<_>>()))
1056 .flatten();
1057 rows.push(Planned { write, issue, proceed: true, choice: edited_last.unwrap_or(0), edited_last });
1058 }
1059 for key in unsaved_edits(¤t).into_keys() {
1062 if !writable(&key) || rows.iter().any(|r| r.write.key == key) || promotions.iter().any(|p| p.old_key == key) {
1063 continue;
1064 }
1065 let mut copies: Vec<(Vec<String>, &Value)> = copies_of(¤t, &key)
1068 .into_iter()
1069 .filter_map(|names| super::work_part::entry_at(¤t, &names).map(|entry| (names, entry)))
1070 .collect();
1071 copies.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
1072 if let ReadNow::Ready(file) = crate::store::read_now(store, &key) {
1074 let file = text_hash(&file);
1075 let changed: Vec<_> = copies.iter().filter(|(_, entry)| source_hash(&entry["document"]) != file).cloned().collect();
1076 if !changed.is_empty() {
1077 copies = changed;
1078 }
1079 }
1080 let Some((names, entry)) = copies.first().cloned() else { continue };
1081 let mut variants: Vec<(Vec<String>, String)> = Vec::new();
1082 for (names, entry) in &copies {
1083 let text = part_text(&entry["document"]);
1084 if !variants.iter().any(|(_, known)| text_hash(known) == text_hash(&text)) {
1085 variants.push((names.clone(), text));
1086 }
1087 }
1088 let write = OwnedWrite {
1089 key,
1090 depth: names.len(),
1091 text: variants[0].1.clone(),
1092 variants,
1093 names,
1094 synced: entry["sourceSignature"].as_str().unwrap_or("").to_string(),
1095 };
1096 let (issue, _) = issue_of(store, &write);
1097 let edited_last = (issue == Some(Issue::Conflict))
1098 .then(|| edited_last(&write, &self.open[self.active].engine.history.undo_parts_libraries().collect::<Vec<_>>()))
1099 .flatten();
1100 rows.push(Planned { write, issue, proceed: true, choice: edited_last.unwrap_or(0), edited_last });
1101 }
1102 let mut moved: Vec<Vec<String>> = promotions.iter().map(|p| p.names.clone()).collect();
1107 for row in &rows {
1108 if matches!(row.issue, Some(Issue::SaveAsNew { .. })) {
1109 moved.extend(copies_of(¤t, &row.write.key));
1110 }
1111 }
1112 for names in &moved {
1113 for depth in 1..names.len() {
1114 let names = &names[..depth];
1115 let Some(entry) = super::work_part::entry_at(¤t, names) else { continue };
1116 let key = entry["sourceKey"].as_str().unwrap_or("");
1117 if !writable(key)
1118 || promotions.iter().any(|p| p.names == names || p.old_key == key)
1119 || rows.iter().any(|r| r.write.key == key)
1120 {
1121 continue;
1122 }
1123 let text = part_text(&entry["document"]);
1124 let write = OwnedWrite {
1125 key: key.to_string(),
1126 names: names.to_vec(),
1127 depth,
1128 variants: vec![(names.to_vec(), text.clone())],
1129 text,
1130 synced: entry["sourceSignature"].as_str().unwrap_or("").to_string(),
1131 };
1132 let (issue, _) = issue_of(store, &write);
1133 rows.push(Planned { write, issue, proceed: true, choice: 0, edited_last: None });
1134 }
1135 }
1136 rows.sort_by(|a, b| b.write.depth.cmp(&a.write.depth));
1137 OwnedSavePlan { doc_id: self.active_id(), rows, promotions, saving_as: saving_as.to_string() }
1138 }
1139
1140 pub fn save_part_as(
1149 &mut self,
1150 index: usize,
1151 store: &dyn ModelStore,
1152 component_path: &str,
1153 key: &str,
1154 overwrite: bool,
1155 ) -> Result<(), String> {
1156 let doc = self.open.get(index).ok_or("the assembly's tab was closed")?;
1157 let mut current: Value = serde_json::from_str(&doc.engine.history_request_json()).map_err(|e| e.to_string())?;
1158 let (_, names, source, document) = super::work_part::resolve_occurrence(&doc.engine, ¤t, component_path)?;
1159 if let Some(source) = source.filter(|source| !source.is_empty()) {
1160 let part = names.last().map(String::as_str).unwrap_or("");
1161 return Err(format!("'{part}' already has a file of its own ({source}): open it and use Save As"));
1162 }
1163 if key.trim().is_empty() {
1164 return Err("enter a name for the part file".into());
1165 }
1166 if !overwrite && store.residency(key) != crate::store::Residency::Absent {
1167 return Err(format!("'{key}' already exists: choose another name"));
1168 }
1169 let same = source_hash(&document);
1170 let copies: Vec<Vec<String>> = plan_promotions(store, ¤t, doc.name().unwrap_or_default())
1171 .into_iter()
1172 .filter(|p| p.old_key.is_empty())
1173 .filter(|p| super::work_part::entry_at(¤t, &p.names).is_some_and(|e| source_hash(&e["document"]) == same))
1174 .map(|p| p.names)
1175 .collect();
1176 if copies.is_empty() {
1177 return Err("this part is not one the assembly holds without a file".into());
1178 }
1179 let text = part_text(&document);
1180 store.write(key, &text)?;
1181 let signature = document_signature(&text);
1182 for names in &copies {
1183 set_entry_source(&mut current, names, key, &signature);
1184 }
1185 self.open[index]
1186 .engine
1187 .edit_document_json(¤t.to_string())
1188 .map_err(|e| format!("'{key}' was written, but the assembly could not be linked to it: {e}"))?;
1189 Ok(())
1190 }
1191
1192 pub fn execute_owned_save(&mut self, store: &dyn ModelStore, plan: OwnedSavePlan) -> (OwnedSaveReport, Vec<CheckoutRequest>) {
1198 let mut report = OwnedSaveReport::default();
1199 let mut checkouts = Vec::new();
1200 if plan.doc_id != self.active_id() {
1201 return (report, checkouts);
1202 }
1203 let active = self.active;
1204 let mut document = self.open[active].engine.history_request_value();
1205 let mut changed = false;
1208 let mut rebuilt = false;
1209 let mut marks = false;
1211 let mut notes = Vec::new();
1212 for row in plan.rows.iter().filter(|r| r.proceed && r.issue == Some(Issue::Conflict)) {
1215 if let Some((names, text)) = row.write.variants.get(row.choice) {
1216 if let Ok(kept) = serde_json::from_str::<Value>(text) {
1217 rebuilt |= super::work_part::refresh_parts(&mut document, names, Some(&row.write.key), &kept, &mut notes)
1218 .unwrap_or(false);
1219 }
1220 }
1221 }
1222 let mut steps: Vec<(usize, Option<Planned>, Option<Promotion>)> = plan
1225 .promotions
1226 .into_iter()
1227 .map(|p| (p.names.len(), None, Some(p)))
1228 .chain(plan.rows.into_iter().map(|r| (r.write.depth, Some(r), None)))
1229 .collect();
1230 steps.sort_by(|a, b| b.0.cmp(&a.0));
1231 let mut taken: Vec<String> = Vec::new();
1232 for (_, row, promotion) in steps {
1233 if let Some(promotion) = promotion {
1234 let engine = &mut self.open[active].engine;
1235 rebuilt |= promote(engine, store, &mut document, &promotion, &plan.saving_as, &mut taken, &mut report);
1236 continue;
1237 }
1238 let Some(row) = row else { continue };
1239 let key = row.write.key.clone();
1240 let part = row.write.names.last().cloned().unwrap_or_else(|| key.clone());
1241 report.shown.insert(key.clone(), shown(&key, &row.write.names));
1242 let Some(text) = super::work_part::entry_at(&document, &row.write.names).map(|e| e["document"].to_string()) else {
1245 continue;
1246 };
1247 let engine = &mut self.open[active].engine;
1248 let outcome = match (&row.issue, row.proceed) {
1249 (Some(Issue::Blocked(why)), _) => {
1250 marks |= mark_unsaved(&mut document, &key, why);
1251 report.skipped.push((key, why.clone()));
1252 continue;
1253 }
1254 (Some(_), false) => {
1255 marks |= mark_unsaved(&mut document, &key, "you chose not to save it");
1256 report.skipped.push((key, "you chose not to save it".into()));
1257 continue;
1258 }
1259 (Some(Issue::CheckOut { part, revision, .. }), true) => {
1260 report.checking_out.push(key.clone());
1261 checkouts.push(CheckoutRequest { shown: shown(&key, &row.write.names), key, part: part.clone(), revision: revision.clone(), text, new_revision: false });
1262 continue;
1263 }
1264 (Some(Issue::NewRevision { part, .. }), true) => {
1265 marks |= mark_unsaved(&mut document, &key, "a new revision is being made for it");
1268 report.revising.push(key.clone());
1269 checkouts.push(CheckoutRequest { shown: shown(&key, &row.write.names), key, part: part.clone(), revision: String::new(), text, new_revision: true });
1270 continue;
1271 }
1272 (Some(Issue::SaveAsNew { new_key, .. }), true) => {
1273 let mut body: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
1276 if let Some(object) = body.as_object_mut() {
1277 object.remove(crate::family_table::FAMILY_SOURCE_KEY);
1278 }
1279 let body_text = body.to_string();
1280 match store.write(new_key, &body_text) {
1281 Ok(()) => {
1282 engine.push_notice_as(NoticeSeverity::Info, format!("Saved '{part}' as a new file '{new_key}' (was '{key}')"));
1283 rebuilt |= stamp_copies(&mut document, &key, &document_signature(&body_text), Some((new_key, &body)));
1284 marks |= clear_unsaved(&mut document, &key);
1285 report.written.push(new_key.clone());
1286 }
1287 Err(error) => {
1288 engine.push_notice(format!("Could not save '{new_key}': {error} — '{part}' is kept in the assembly"));
1289 report.skipped.push((key, format!("write failed: {error}")));
1290 }
1291 }
1292 continue;
1293 }
1294 (Some(Issue::Overwrite), true) => match store.write(&key, &text) {
1295 Ok(()) => {
1296 engine.push_notice_as(NoticeSeverity::Info, format!("{} (overwrote the newer document)", saved_notice(&part, &key)));
1297 WriteOutcome::Written
1298 }
1299 Err(error) => {
1300 engine.push_notice(format!("Could not save {}: {error} — the change is kept in the assembly", shown(&key, std::slice::from_ref(&part))));
1301 WriteOutcome::Refused(format!("write failed: {error}"))
1302 }
1303 },
1304 (None | Some(Issue::Conflict), _) => crate::panels::parts_library::write_through(
1305 engine,
1306 store,
1307 &part,
1308 Some(&(key.clone(), row.write.synced.clone())),
1309 &text,
1310 ),
1311 };
1312 match outcome {
1313 WriteOutcome::Refused(why) => report.skipped.push((key, why)),
1314 outcome => {
1315 let signature = store.read(&key).map_or_else(|| document_signature(&text), |file| document_signature(&file));
1318 changed |= stamp_copies(&mut document, &key, &signature, None);
1319 marks |= clear_unsaved(&mut document, &key);
1320 self.settle_tab(&key, &text);
1321 if outcome == WriteOutcome::Written {
1322 report.written.push(key);
1323 }
1324 }
1325 }
1326 }
1327 let engine = &mut self.open[active].engine;
1328 if rebuilt {
1329 if let Err(error) = engine.edit_document_json(&document.to_string()) {
1330 engine.push_notice(format!("Save: the assembly could not take the saved documents: {error}"));
1331 }
1332 } else if changed {
1333 stamp_library(engine, &document);
1334 }
1335 if marks && !rebuilt {
1336 store_unsaved_block(engine, &document);
1337 }
1338 for note in notes {
1339 engine.push_notice(format!("Save: {note}"));
1340 }
1341 (report, checkouts)
1342 }
1343
1344 pub fn mark_unsaved_copies(&mut self, doc_id: u64, key: &str, why: &str) -> bool {
1350 let Some(doc) = self.open.iter_mut().find(|d| d.id() == doc_id) else { return false };
1351 edit_unsaved_block(&mut doc.engine, |marks| mark_unsaved(marks, key, why))
1352 }
1353
1354 pub fn mark_failed_copies(&mut self, store: &dyn ModelStore, key: &str) -> Vec<u64> {
1362 let canonical = store.canonical_identity(key);
1363 let holders: Vec<(u64, bool)> = self
1364 .open
1365 .iter()
1366 .filter(|doc| {
1367 serde_json::from_str::<Value>(&doc.engine.history_request_json())
1368 .is_ok_and(|document| !copies_of(&document, key).is_empty() || !copies_of(&document, &canonical).is_empty())
1369 })
1370 .map(|doc| (doc.id(), !doc.is_dirty()))
1371 .collect();
1372 let mut marked = Vec::new();
1373 for (id, was_clean) in holders {
1374 if self.mark_unsaved_copies(id, key, "its last save did not reach the PLM") {
1375 if was_clean {
1379 let _ = self.write_unsaved_marks(store, id, true);
1380 }
1381 marked.push(id);
1382 }
1383 }
1384 marked
1385 }
1386
1387 pub fn write_unsaved_marks(&mut self, store: &dyn ModelStore, doc_id: u64, was_clean: bool) -> Result<(), String> {
1394 let doc = self.open.iter_mut().find(|d| d.id() == doc_id).ok_or("the assembly's tab was closed")?;
1395 let name = doc.name().ok_or("the assembly has no name")?.to_string();
1396 let stored = store.read(&name).ok_or_else(|| format!("'{name}' is not in the store"))?;
1397 let mut stored: Value = serde_json::from_str(&stored).map_err(|e| e.to_string())?;
1398 let current: Value = serde_json::from_str(&doc.engine.history_request_json()).map_err(|e| e.to_string())?;
1399 let object = stored.as_object_mut().ok_or_else(|| format!("'{name}' is not a document"))?;
1400 match current.get(UNSAVED_EDITS_BLOCK) {
1401 Some(block) => object.insert(UNSAVED_EDITS_BLOCK.to_string(), block.clone()),
1402 None => object.remove(UNSAVED_EDITS_BLOCK),
1403 };
1404 store.write(&name, &stored.to_string())?;
1405 if was_clean {
1406 doc.mark_clean();
1407 }
1408 Ok(())
1409 }
1410
1411 pub fn retarget_revised_copies(&mut self, doc_id: u64, old_key: &str, new_key: &str, text: &str) -> Result<(), String> {
1416 let doc = self.open.iter_mut().find(|d| d.id() == doc_id).ok_or("the assembly's tab was closed")?;
1417 let mut document: Value = serde_json::from_str(&doc.engine.history_request_json()).map_err(|e| e.to_string())?;
1418 let body: Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
1419 stamp_copies(&mut document, old_key, &document_signature(text), Some((new_key, &body)));
1420 clear_unsaved(&mut document, old_key);
1421 doc.engine.edit_document_json(&document.to_string()).map(|_| ())
1422 }
1423
1424 pub fn clear_unsaved_copies(&mut self, doc_id: u64, key: &str) -> bool {
1428 let Some(doc) = self.open.iter_mut().find(|d| d.id() == doc_id) else { return false };
1429 edit_unsaved_block(&mut doc.engine, |marks| clear_unsaved(marks, key))
1430 }
1431
1432 pub fn settle_tab(&mut self, key: &str, text: &str) {
1435 let Some(index) = self.index_of_source(key).filter(|&index| index != self.active) else {
1436 return;
1437 };
1438 let doc = &mut self.open[index];
1439 let same = serde_json::from_str::<Value>(&doc.engine.history_request_json())
1440 .is_ok_and(|v| source_hash(&v) == text_hash(text));
1441 if same || doc.engine.set_history_json(text).is_ok() {
1442 doc.mark_clean();
1443 }
1444 }
1445}
1446
1447fn promote(
1451 engine: &mut EngineState,
1452 store: &dyn ModelStore,
1453 document: &mut Value,
1454 promotion: &Promotion,
1455 saving_as: &str,
1456 taken: &mut Vec<String>,
1457 report: &mut OwnedSaveReport,
1458) -> bool {
1459 let part = promotion.names.last().cloned().unwrap_or_default();
1460 let Some(body) = super::work_part::entry_at(document, &promotion.names).map(|e| e["document"].clone()) else {
1461 return false;
1462 };
1463 let text = body.to_string();
1464 let mut key = promotion.new_key.clone();
1465 let mut result = store.write(&key, &text);
1466 let mut recreated = promotion.recreates();
1467 if let (Err(error), true) = (&result, recreated) {
1468 let fallback = new_part_key(store, saving_as, &part, taken);
1469 engine.push_notice(format!("Could not re-create '{key}': {error} — saving '{part}' as '{fallback}'"));
1470 key = fallback;
1471 recreated = false;
1472 result = store.write(&key, &text);
1473 }
1474 if let Err(error) = result {
1475 engine.push_notice(format!("Could not create '{key}' for '{part}': {error} — try Save again"));
1476 report.skipped.push((key.clone(), format!("could not create the file: {error}")));
1477 report.failed_promotions.push(key);
1478 return false;
1479 }
1480 let signature = document_signature(&text);
1481 let changed = if promotion.old_key.is_empty() {
1482 set_entry_source(document, &promotion.names, &key, &signature)
1483 } else {
1484 let retarget = (key != promotion.old_key).then_some((key.as_str(), &body));
1485 stamp_copies(document, &promotion.old_key, &signature, retarget)
1486 };
1487 if recreated {
1488 engine.push_notice_as(NoticeSeverity::Info, format!("Re-created the missing file '{key}' for '{part}'"));
1489 report.recreated.push(key);
1490 } else {
1491 engine.push_notice_as(NoticeSeverity::Info, format!("Saved '{part}' as a new part file '{key}'"));
1492 if !report.created.contains(&key) {
1493 report.created.push(key);
1494 }
1495 }
1496 changed
1497}
1498
1499fn stamp_library(engine: &mut EngineState, document: &Value) {
1505 let library = document["partsLibrary"].clone();
1506 if let Some(entries) = library.as_object() {
1507 let before = engine.history.parts_library().clone();
1508 for (name, entry) in entries {
1509 if before.get(name) != Some(entry) {
1510 let signature = entry["sourceSignature"].as_str().unwrap_or("");
1511 if let Err(error) = brep_render::brep_kernel::refresh_library_entry_impl(name, signature, &entry["document"].to_string()) {
1512 engine.push_notice(format!("Save: {error}"));
1513 }
1514 }
1515 }
1516 }
1517 engine.history.set_parts_library(library);
1518}
1519
1520pub fn finish_checkout(state: &mut EngineState, store: &dyn ModelStore, request: &CheckoutRequest, result: Result<(), String>) -> bool {
1523 if result.is_ok() {
1524 store.note_checked_out(&request.key);
1527 }
1528 match result.and_then(|()| store.write(&request.key, &request.text)) {
1529 Ok(()) => {
1530 state.push_notice_as(NoticeSeverity::Info, format!("Checked out and saved {}", request.shown));
1531 true
1532 }
1533 Err(error) => {
1534 state.push_notice(format!("{} NOT saved: {error} — the change is kept in the assembly", request.shown));
1535 false
1536 }
1537 }
1538}
1539
1540pub(crate) fn nested_entries(library: &Value) -> Vec<(String, &Value)> {
1544 fn walk<'a>(library: &'a Value, prefix: &str, out: &mut Vec<(String, &'a Value)>) {
1545 for (name, entry) in library.as_object().into_iter().flatten() {
1546 let chain = format!("{prefix}/{name}");
1547 out.push((chain.clone(), entry));
1548 walk(&entry["document"]["partsLibrary"], &chain, out);
1549 }
1550 }
1551 let mut out = Vec::new();
1552 for (name, entry) in library.as_object().into_iter().flatten() {
1553 walk(&entry["document"]["partsLibrary"], name, &mut out);
1554 }
1555 out
1556}
1557
1558pub(crate) fn entry_at_chain<'a>(library: &'a Value, chain: &str) -> Option<&'a Value> {
1560 let mut parts = chain.split('/');
1561 let mut entry = library.get(parts.next()?)?;
1562 for name in parts {
1563 entry = entry.get("document")?.get("partsLibrary")?.get(name)?;
1564 }
1565 Some(entry)
1566}
1567