Skip to main content

brep_app/document/
owned_save.rs

1//! Save an assembly INTO the documents it owns (NX "Save" in an assembly).
2//!
3//! An assembly embeds a copy of every part and sub-assembly it places
4//! (`partsLibrary.<name>.document`); a copy with a `sourceKey` is a copy of a
5//! document of its own. Every edit made from inside the assembly changes the
6//! embedded copy only: a Work part edit (at any depth, every copy with the
7//! same source), a BOM `part.` cell, a BOM `occurrence.` cell of a NESTED
8//! placement (which lives in the sub-assembly's definition), and an edit made
9//! in the part's own open tab, which reaches the assembly through the live
10//! link ([`super::live_link`]). The assembly's Save then writes every copy
11//! that CHANGED back to the document it came from, so opening that document
12//! alone shows the change.
13//!
14//! WHAT changed is one rule, not a list each edit path must remember to feed:
15//! the assembly's CURRENT document against its clean BASELINE (the request JSON
16//! as of the last Open/Save, the same baseline the dirty dot compares). A copy
17//! whose document differs from the baseline's copy at the same place is
18//! modified. That makes the written state the CURRENT one — an edit that was
19//! undone back to the original writes nothing — and catches a sub-assembly
20//! whose embedded part changed, so its own file does not keep a stale part.
21//!
22//! WRITE ORDER: deepest documents first (parts before the sub-assemblies that
23//! embed them), then the assembly itself (the caller). Each written document's
24//! `sourceSignature` is stamped on every copy of it before its parents are
25//! read, so a parent's file and the assembly agree with what was written.
26//!
27//! NOTHING IS BLOCKED THAT THE USER CAN SAY YES TO (owner, 2026-10-20). A Save
28//! is first PLANNED ([`Documents::plan_owned_save`]); a document that needs a
29//! decision is a row of one Save dialog, every row defaulting to proceed:
30//! * changed on disk since the assembly loaded it → overwrite it;
31//! * a PLM item not checked out to this user → check it out, then save it
32//!   through the same store write the PLM's own Save uses;
33//! * gone from storage, or a family member → save it as a NEW file next to
34//!   the old one; every copy in the assembly moves to the new file;
35//! * two copies changed to different documents (only a hand-edited file can
36//!   do that now: every in-app edit changes every copy of a source) → keep
37//!   one (default: the deepest copy); every copy becomes that one.
38//!
39//! A row the user unticks is not written; its change stays in the assembly.
40//! Only what nobody can say yes to is reported by name and skipped: a PLM item
41//! checked out by someone else or not editable (released), or one still
42//! loading. An open tab of a written document is marked clean when it shows
43//! what was written, and reloaded when not.
44//!
45//! EVERY PLACED DOCUMENT HAS A FILE AFTER A SAVE (owner, 2026-10-21: "if a part
46//! does not exist it should save to a new part file; never fall back to saving
47//! in the assembly"). Changed or not, a copy is PROMOTED ([`Promotion`]):
48//! * with no `sourceKey` (an import, a part made in context, an old document)
49//!   → a new `<part name>.nbrep` in the assembly's folder, `-2`, `-3`, … when
50//!   the name is taken;
51//! * whose file is gone → re-created at its old key, so every reference to it
52//!   stays valid; when that write fails, a new file in the assembly's folder.
53//!
54//! Each promotion relinks every copy and is written deepest first with the
55//! rows, so the sub-assembly that holds a promoted part is written after it
56//! and names its file (a row is added for it when it had no change of its
57//! own). Nothing asks: the summary names each file made.
58use 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/// One owned document the assembly's Save will write.
66#[derive(Debug, Clone, PartialEq)]
67pub struct OwnedWrite {
68    /// The store identity (`sourceKey`).
69    pub key: String,
70    /// The part-name chain to its deepest copy in the assembly.
71    pub names: Vec<String>,
72    /// How deep that copy sits (1 = a top-level component's document).
73    pub depth: usize,
74    /// The copy's CURRENT document (at plan time).
75    pub text: String,
76    /// The `sourceSignature` the baseline copy carried: what the assembly last
77    /// synced with the file. A file with other content changed on disk.
78    pub synced: String,
79    /// Every DIFFERENT changed copy, deepest first, as `(names, document)`;
80    /// more than one is a conflict.
81    pub variants: Vec<(Vec<String>, String)>,
82}
83
84/// `(tab id, edit key, baseline generation)` of a cached answer, and the keys
85/// ([`Documents::owned_modified_keys_of`]).
86pub type OwnedCacheKey = ((u64, (u64, u64), u64), Vec<String>);
87
88/// `((tab id, baseline generation), entry → synced signature)`
89/// ([`Documents::synced_signatures`]).
90pub type SyncedCache = ((u64, u64), std::collections::HashMap<String, String>);
91
92/// What the assembly's Save would write: the changed owned documents,
93/// deepest first.
94#[derive(Debug, Clone, Default, PartialEq)]
95pub struct OwnedChanges {
96    pub writes: Vec<OwnedWrite>,
97}
98
99/// A placed document the Save gives a file of its own ([module docs](self)).
100#[derive(Debug, Clone, PartialEq)]
101pub struct Promotion {
102    /// The part-name chain to the copy (outermost first).
103    pub names: Vec<String>,
104    /// The key it had: `""` when it never had a file, else its gone file.
105    pub old_key: String,
106    /// Where it is written.
107    pub new_key: String,
108}
109
110impl Promotion {
111    /// Re-creating a missing file rather than making a new one.
112    pub fn recreates(&self) -> bool {
113        !self.old_key.is_empty() && self.old_key == self.new_key
114    }
115}
116
117/// The decision one owned document needs before it is written.
118#[derive(Debug, Clone, PartialEq)]
119pub enum Issue {
120    /// The file changed on disk since the assembly synced with it: write
121    /// over it.
122    Overwrite,
123    /// A PLM revision not checked out to this user: check it out, then save.
124    /// `newer`: the PLM holds a document saved since this assembly last
125    /// synced with it (another user's work), which the save would overwrite;
126    /// asked as an overwrite, even when checkouts are remembered.
127    CheckOut { part: String, revision: String, newer: bool },
128    /// The source cannot take the change (`why`): save it as `new_key`.
129    SaveAsNew { why: String, new_key: String },
130    /// Copies changed differently: keep [`Planned::choice`] of
131    /// [`OwnedWrite::variants`].
132    Conflict,
133    /// A released (not editable) PLM revision: make the part's next
134    /// revision, check it out and save the change into it; every copy then
135    /// names the new revision and the documents that hold one are saved
136    /// again. `from` is the revision's label and state, for the question.
137    NewRevision { part: String, from: String },
138    /// Nobody can say yes here (checked out by someone else, still loading).
139    Blocked(String),
140}
141
142/// One row of a planned Save.
143#[derive(Debug, Clone, PartialEq)]
144pub struct Planned {
145    pub write: OwnedWrite,
146    pub issue: Option<Issue>,
147    /// The user's answer; every question defaults to yes.
148    pub proceed: bool,
149    /// For [`Issue::Conflict`]: which variant to keep.
150    pub choice: usize,
151    /// For [`Issue::Conflict`]: the variant edited most recently, which is
152    /// also the default `choice` (`None` when the undo history cannot tell).
153    pub edited_last: Option<usize>,
154}
155
156/// How a Save names one document to the user: a PLM revision by its part's
157/// name with its number and revision (`plate (CPART000000003 rev A)`, not
158/// its storage key), a file by its key.
159pub 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
167/// The notice for `part` written to `key`: a PLM revision named as [`shown`]
168/// names it (`Saved sub-b (CPART000000004 rev A)`), a file by its key as before.
169pub 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    /// The dialog's line for this row.
179    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    /// Whether this row is a question for the user.
207    pub fn asks(&self) -> bool {
208        matches!(&self.issue, Some(issue) if !matches!(issue, Issue::Blocked(_)))
209    }
210}
211
212/// A planned Save of the active tab's owned documents.
213#[derive(Debug, Clone, Default, PartialEq)]
214pub struct OwnedSavePlan {
215    /// The tab being saved (a plan never applies to another one).
216    pub doc_id: u64,
217    pub rows: Vec<Planned>,
218    /// Deepest first.
219    pub promotions: Vec<Promotion>,
220    /// The name the assembly is being saved under (a failed re-create falls
221    /// back to a new file beside it).
222    pub saving_as: String,
223}
224
225impl OwnedSavePlan {
226    /// Whether the Save dialog must ask before writing.
227    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/// A PLM item to write once its checkout lands.
260#[derive(Debug, Clone, PartialEq)]
261pub struct CheckoutRequest {
262    pub key: String,
263    pub part: String,
264    pub revision: String,
265    pub text: String,
266    /// How the notices name it ([`shown`]).
267    pub shown: String,
268    /// Not a checkout of `revision` but a NEW revision of `part`
269    /// ([`Issue::NewRevision`]): made, checked out and written with `text`;
270    /// its key replaces `key` in every copy ([`Documents::retarget_copies`]).
271    pub new_revision: bool,
272}
273
274/// What one Save of an assembly did with its owned documents.
275#[derive(Debug, Clone, Default, PartialEq)]
276pub struct OwnedSaveReport {
277    pub written: Vec<String>,
278    /// `(key, reason)`: not written, the change is kept in the assembly.
279    pub skipped: Vec<(String, String)>,
280    /// New files made for documents that had none.
281    pub created: Vec<String>,
282    /// Missing files written again at their old key.
283    pub recreated: Vec<String>,
284    /// Parts that had no file and could not be given one: the caller must not
285    /// write the assembly, which would keep them only inside it.
286    pub failed_promotions: Vec<String>,
287    /// PLM items being checked out; each is written when its checkout lands.
288    pub checking_out: Vec<String>,
289    /// Released PLM items whose change goes into a new revision, made when
290    /// the Save's requests land.
291    pub revising: Vec<String>,
292    /// How the summary names each key above ([`shown`]); a key missing here
293    /// is named by itself.
294    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    /// The Save summary line ("" when the assembly owns nothing that changed).
311    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
337/// A `sourceKey` that names a document of its own: not embedded-only, not a
338/// reserved key.
339pub(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
347/// What a document IS, for telling two states of one source apart: its
348/// stable hash without the fields a save stamps from the window it was saved
349/// in (`thumbnail`, `workbench`) and without the mate solver's run record
350/// ([`super::without_run_record`]). A part's own tab and its copy in an
351/// assembly differ in exactly those, and are still the same part.
352pub(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
365/// Fill in every copy `current` holds that `baseline` lacks (inserted after
366/// the last Open/Save) with the copy as it was inserted, so an edit made to it
367/// since is owed like any other. `libraries` is the `partsLibrary` block before
368/// each undoable edit, newest first: the oldest of the newest run that holds
369/// the copy is the state right after the insert (an insert, then a delete and a
370/// re-insert, finds the second). No snapshot holds it when nothing was edited
371/// since the insert, so the current copy is its own baseline. A top-level copy
372/// `current`'s [`INSERTED_COPIES_BLOCK`] noted takes `noted_baseline` instead,
373/// which neither the undo limit nor a crash recovery loses.
374pub 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
412/// The entry at `names` (outermost first) in a top-level `partsLibrary` block.
413fn 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
422/// Every changed copy in `current` against `baseline` (both whole assembly
423/// documents), deepest first. A copy absent from the baseline is not counted:
424/// [`fill_inserted`] gives each inserted copy its inserted state first.
425pub 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    // Deepest first, so the first copy of each document is its deepest.
429    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        // The same document as the baseline's, down to its last copy: nothing
458        // in it changed. One comparison, which stops at the first difference,
459        // instead of hashing (and cloning) the subtree at every level of it —
460        // on l500 that was ~50 ms of every edit and every Save.
461        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            // A copy inside it changed, so it did too: its hash covers
470            // everything a nested copy's does. Only where nothing below moved
471            // are the two (cloned, hashed) subtrees compared.
472            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
492/// A new file next to `key` for a change its source cannot take:
493/// `bolt.nbrep` → `bolt-edited.nbrep` (then `bolt-edited-2.nbrep`, …), one
494/// the store does not hold yet.
495pub 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
510/// A key for a new part file named after `part`, beside `saving_as`: the
511/// first of `<part>.nbrep`, `<assembly>-<part>.nbrep` (so two imports' `Body`
512/// parts say which assembly they came from), `<assembly>-<part>-2.nbrep`, …
513/// that the store does not hold and this Save has not given out (`taken`).
514pub 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
532/// Every copy in `current` with no file of its own, deepest first: no
533/// `sourceKey`, or one the store no longer holds. Copies with the same
534/// content share one new file; copies of one gone file are re-created once.
535pub 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; // one re-create relinks every copy
546            }
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
581/// Which variant of a conflicting `write` was edited most recently: the one
582/// whose copy differs from the NEWEST undo snapshot (`libraries`, newest
583/// first, each the `partsLibrary` block before an edit). Ties — copies changed
584/// by the same edit, or a change the undo history no longer holds — keep the
585/// deepest (the lowest index); `None` when no variant changed within it.
586fn edited_last(write: &OwnedWrite, libraries: &[&Value]) -> Option<usize> {
587    let mut best: Option<(usize, usize)> = None; // (snapshot age, variant)
588    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
602/// Every copy of `key` in `document`, at any depth, as its part-name chain.
603fn 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
620/// Point the copy at `names` at `key` (and its signature). `true` when it
621/// changed.
622fn 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
642/// The decision `write` needs (`None` = write it as is), and whether it needs
643/// writing at all (`false`: the file already holds it).
644/// Why a Save to the PLM cannot happen for an account without the author
645/// group, in the words the Save's notice and the pane use.
646pub 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            // A viewer's checkout, new revision or write can only be refused:
655            // say so before anyone confirms one.
656            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                    // Released: the change goes into the part's next revision
662                    // (owner 2026-10-21: prompts that default to proceed).
663                    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                    // The checkout writes over whatever the PLM holds now: a
672                    // document someone saved since this assembly synced with
673                    // it is their work, and the question has to say so.
674                    let newer = match crate::store::read_now(store, &write.key) {
675                        ReadNow::Ready(current) => {
676                            text_hash(&current) != text_hash(&write.text)
677                                && document_signature(&current) != 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>(&current)
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(&current) == text_hash(&write.text) {
706        return (None, false);
707    }
708    // The stored copy differs from what this assembly last synced with. Not
709    // a newer document when it is only that this session's own last write of
710    // it never reached the store: then nothing to ask, the Save writes it.
711    let changed_elsewhere = document_signature(&current) != write.synced && !store.write_failed(&write.key);
712    (changed_elsewhere.then_some(Issue::Overwrite), true)
713}
714
715/// The document block naming the sources whose copies in this assembly hold
716/// a change their own documents did not take (checked out by someone else,
717/// released, the user said no, a checkout that failed): `{ "<sourceKey>":
718/// "<why>" }`. The change lives only in this assembly until a Save writes it,
719/// so the next Save asks again ([`Documents::plan_owned_save`]), a successful
720/// write clears the key (`clear_unsaved`), and a reader that refreshes
721/// copies from their sources (`plm::cached_copies`) leaves those copies alone.
722/// It is saved with the assembly, file or PLM alike.
723pub const UNSAVED_EDITS_BLOCK: &str = "unsavedEdits";
724
725/// The document block naming each source a copy was inserted from since the
726/// last Save, with the signature that copy had at the insert: `{ "<sourceKey>":
727/// "<signature>" }`. `sourceSignature` cannot stand in, as every in-app edit
728/// re-stamps it with the edited copy's own signature, and the undo history
729/// loses the insert past `MAX_UNDO` steps or after a crash recovery. Kept up to
730/// date per frame (`note_inserted_copies`), without undo; it never makes a
731/// document unsaved (the clean signature leaves it out) and is never written
732/// into a part's own file (`part_text`).
733pub const INSERTED_COPIES_BLOCK: &str = "insertedCopies";
734
735/// Bring `doc`'s [`INSERTED_COPIES_BLOCK`] up to date: a top-level copy whose
736/// source the clean baseline does not hold is noted with its signature the
737/// first time it is seen, so before any edit to it; a source the baseline
738/// holds, or no copy uses any more, is dropped.
739pub(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
769/// The baseline of a top-level copy inserted since the last Save, from the
770/// signature [`INSERTED_COPIES_BLOCK`] noted at its insert: the source file
771/// itself while the store still holds that version; the copy as it is when
772/// its signature is still that one (untouched, or edited back); otherwise
773/// nothing, so the copy is owed, and asked about if its file moved on.
774fn 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
794/// The text a part's own file is written with: its copy, without the
795/// assembly's own [`INSERTED_COPIES_BLOCK`] should it hold one.
796fn 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
807/// The keys marked in `document`'s [`UNSAVED_EDITS_BLOCK`], with why.
808pub 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
815/// Mark `key` in `document` as holding a change its source did not take,
816/// because `why`. `true` when anything changed.
817pub(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
830/// `key` was written: drop its mark. `true` when anything changed.
831pub(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
840/// The first copy (`names` path) of `key` in `document`, any depth.
841pub(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
856/// Put `document`'s [`UNSAVED_EDITS_BLOCK`] into the tab's history as it stands
857/// (no undo step: it is the Save's own bookkeeping).
858fn 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
865/// Change the tab's [`UNSAVED_EDITS_BLOCK`] with `edit` (handed a document
866/// holding that block alone) and put it back when `edit` says it changed. The
867/// block is all the marks are: an l500 assembly is megabytes of text, and a
868/// Save whose ten checkouts land in one frame paid ten round trips of it.
869fn 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
881/// Stamp every copy of `key` in `document` (any depth) with `signature`, and
882/// with `retarget` move it to a new key and body. `true` when anything
883/// changed.
884fn stamp_copies(document: &mut Value, key: &str, signature: &str, retarget: Option<(&str, &Value)>) -> bool {
885    // `get_mut`, never `document["partsLibrary"]`: indexing a document that
886    // has no library INSERTS a null one, which no part document parses.
887    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
910/// `text` (a document) with every copy of each `(old key, new key, body)` in
911/// `done` re-pointed to the new key and body: a new revision made earlier in
912/// the same Save, deeper in the tree, so a holder's new revision names it.
913pub 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    /// The clean baseline and current document of the tab at `index`. A tab
926    /// with no clean baseline (restored after a crash) is measured, on the
927    /// Save's path (`store`), against its file as stored; untitled, against
928    /// nothing, so its copies count as inserted.
929    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, &current, &libraries, store);
944        Some((baseline, current))
945    }
946
947    /// What the active tab's next Save writes besides itself.
948    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, &current))
955            .unwrap_or_default()
956    }
957
958    /// Each top-level parts-library entry's `sourceSignature` as of the
959    /// active tab's last Open/Save: the file version it last synced with
960    /// (`panels::update_components` judges "outdated" against it). An entry
961    /// added since (an insert) is synced with the version it was first seen
962    /// at, which is the insert's. Cached per baseline.
963    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        // A nested entry, by its chain: one added after the baseline is
980        // learned at first sight, like a top-level one.
981        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        // The nested entries' synced versions, by chain (`module/part`): one
1002        // parse per baseline, cached with the rest.
1003        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    /// The source documents the active tab's next Save writes (`[key]`).
1015    pub fn owned_modified_keys(&self) -> Vec<String> {
1016        self.owned_modified_keys_of(self.active_id())
1017    }
1018
1019    /// The source documents the next Save of tab `id` writes, cached per edit
1020    /// (the Work part bar asks every frame; the diff serializes the assembly).
1021    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    /// Save step one: what the active tab's Save would write besides itself,
1037    /// and the decisions it needs. `saving_as` is the name the tab is about
1038    /// to be written under.
1039    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, &current, 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; // the tab itself is the caller's write
1047            }
1048            if promotions.iter().any(|p| p.old_key == write.key) {
1049                continue; // its file is gone: re-created by the promotion
1050            }
1051            // `needed == false`: the file already holds it (saved from its
1052            // own tab); the row still stamps the signature and settles the tab.
1053            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        // A copy an earlier Save could not write is still owed, changed again
1060        // or not: ask again.
1061        for key in unsaved_edits(&current).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            // Every copy, deepest first, one variant per distinct text: copies
1066            // that differ are asked about like any changed row's.
1067            let mut copies: Vec<(Vec<String>, &Value)> = copies_of(&current, &key)
1068                .into_iter()
1069                .filter_map(|names| super::work_part::entry_at(&current, &names).map(|entry| (names, entry)))
1070                .collect();
1071            copies.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
1072            // A copy that is its file as stored holds no kept change.
1073            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        // A document holding a copy that moves to another file names that
1103        // file: it is written too, even when nothing else in it changed. A
1104        // copy moves when it is promoted, or saved as a new file (a family
1105        // member's change), at whatever depth it is held.
1106        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(&current, &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(&current, 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    /// "Save part as…": the file-less part placed at `component_path` in the
1141    /// tab at `index` (it lives only inside that assembly) gets the new part
1142    /// file `key` now, under the name the user chose, and every file-less copy
1143    /// of the same part, at any depth, is linked to it — what the Save's own
1144    /// promotion does under the library entry's name. Nothing stays embedded.
1145    /// The assembly is then unsaved: its next Save keeps the reference. A part
1146    /// that has a file of its own is refused; a name already taken only with
1147    /// `overwrite` (the dialog asks first).
1148    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, &current, 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, &current, doc.name().unwrap_or_default())
1171            .into_iter()
1172            .filter(|p| p.old_key.is_empty())
1173            .filter(|p| super::work_part::entry_at(&current, &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(&current.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    /// Save step two, BEFORE the active tab itself is written: carry out
1193    /// `plan` (with the user's answers), deepest first. The assembly's
1194    /// own document takes the signatures (and any new file names) of what was
1195    /// written. PLM checkouts come back to the caller, which writes each one
1196    /// when its checkout lands.
1197    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        // `changed`: signatures stamped (no geometry moved); `rebuilt`: a copy
1206        // took another document or file name, which the assembly must re-run.
1207        let mut changed = false;
1208        let mut rebuilt = false;
1209        // The [`UNSAVED_EDITS_BLOCK`] moved (a copy kept, or one now written).
1210        let mut marks = false;
1211        let mut notes = Vec::new();
1212        // A conflict resolved: every copy becomes the kept one, before any
1213        // parent is read.
1214        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        // Rows and promotions, deepest first (a promotion before a row at the
1223        // same depth: neither holds the other).
1224        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            // The CURRENT text of this copy: a child written (or moved to a
1243            // new file) before it is already stamped into it.
1244            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                    // Kept (and marked) until the new revision holds it: an app
1266                    // closed before the answer lands loses nothing.
1267                    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                    // A copy of its own: a family member's stamp does not
1274                    // come with it (as Save As drops it).
1275                    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                    // The FILE's signature: what the file holds may differ from
1316                    // the copy in its thumbnail alone (saved from its own tab).
1317                    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    /// A PLM checkout the Save asked for failed after the assembly was written:
1345    /// mark `key`'s copies in the tab `doc_id` as holding a change not saved to
1346    /// it, so the tab reads unsaved, the next Save asks again and no refresh
1347    /// replaces the copy. `true` when the mark is new: the assembly as stored
1348    /// was written without it, so it must be saved again.
1349    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    /// The write of the stored document `key` failed after the Save that made
1355    /// it returned (a write-behind store: the server down, a refusal). Every
1356    /// open document holding a copy of it marks that copy unsaved, as a
1357    /// failed checkout does, and the mark goes into its stored document too:
1358    /// the copy was stamped as written, so without the mark the next Save
1359    /// would skip it and the next open would refresh it away. Returns the
1360    /// tabs marked.
1361    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                // A clean tab is its stored document: mark that too. An
1376                // unsaved one (its own write failed as well) is written with
1377                // the mark by its next Save.
1378                if was_clean {
1379                    let _ = self.write_unsaved_marks(store, id, true);
1380                }
1381                marked.push(id);
1382            }
1383        }
1384        marked
1385    }
1386
1387    /// A mark [`Self::mark_unsaved_copies`] added after the assembly `doc_id`
1388    /// was written: write its stored document again with the tab's marks and
1389    /// nothing else of the tab, so the stored assembly that holds the change
1390    /// also says it is not saved to its item, and no open refreshes it away.
1391    /// `was_clean`: the tab held nothing unsaved before the mark, so it is
1392    /// clean again once written.
1393    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    /// A new revision the Save made holds the change of `old_key`'s copies as
1412    /// `new_key` (`text`): every copy in the tab `doc_id` now names it, and the
1413    /// mark goes. The documents that hold a copy changed with it (they name
1414    /// another revision), so the caller saves the tab again.
1415    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    /// A PLM checkout the Save asked for landed and `key` was written: drop
1425    /// its mark in the tab `doc_id`. `true` when there was one: the assembly
1426    /// as stored still carries it, so it must be saved again.
1427    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    /// The open tab (other than the active one) of `key`, after `text` was
1433    /// written to it: clean when it shows `text`, else reloaded from it.
1434    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
1447/// Write one promoted copy (its CURRENT body in `document`, children already
1448/// relinked) and relink every copy of it. A failed re-create falls back to a
1449/// new file beside the assembly. `true` when the assembly changed.
1450fn 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
1499/// Put the stamped `partsLibrary` of `document` into `engine` WITHOUT a run
1500/// or an undo step: only signatures moved (a nested copy's, or the
1501/// `sourceSignature` of a document just written), nothing that builds. The
1502/// kernel's resident library takes each changed top-level entry too, or the
1503/// next run would mirror the old one back over it.
1504fn 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
1520/// Write one PLM item whose checkout just landed (`result`), through the same
1521/// store write the PLM's own Save uses; the notice names it either way.
1522pub fn finish_checkout(state: &mut EngineState, store: &dyn ModelStore, request: &CheckoutRequest, result: Result<(), String>) -> bool {
1523    if result.is_ok() {
1524        // Ours from now on: a Save planned before the change feed says so
1525        // (the one a new revision's retarget starts) must not ask again.
1526        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
1540/// Every NESTED parts-library entry under `library` (depth two and below), by
1541/// its chain of entry names joined with `/` (`module-1/part-3`): the key
1542/// Update components judges a nested part's file by.
1543pub(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
1558/// The entry at a `/`-joined chain of names under `library`.
1559pub(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