Skip to main content

brep_app/panels/
update_components.rs

1//! # Staleness (recompute on a cheap trigger, never per frame)
2//!
3//! The comparison reads the store once per entry — too hot to recompute on
4//! every panel draw. [`UpdateComponents::ensure_current`] caches the result
5//! keyed on `(EngineState::applied_generation, History::revision,
6//! FileDialog::save_generation, ModelStore::mutation_generation)`:
7//!
8//! * an applied history run bumps the first half (covers opening an assembly,
9//!   inserting components, an update-components refresh, constraint solves);
10//! * a successful store save bumps the third (covers Edit Part → edit → save
11//!   in the part's own tab → back to the assembly tab: the badge lights
12//!   without a restart);
13//! * ANY other write or delete through the store bumps the fourth.
14//!
15//! The fourth is not redundant with the third. `save_generation` counts what
16//! the Save dialog did; the comparison depends on what the STORE holds, and
17//! the dialog is not the only thing that writes to it. `kicad_bulk::write_part`
18//! re-imports a part onto a document name an open assembly may already
19//! reference, and the explorer's delete row (`panels::file`'s `del:` handler)
20//! takes a source away — neither touches the assembly document, so neither
21//! moved any of the other three, and the badge stayed dark until something
22//! unrelated moved. Keying on the store's own mutation counter covers every
23//! door into it by construction, including doors not written yet.
24//!
25//! Entries with an EMPTY `sourceKey` (embedded-only parts) are skipped;
26//! entries whose key has no store document are NOTED (surfaced in the header
27//! hover + a run-time notice) but never counted — they cannot be refreshed.
28//!
29//! The comparison only means "outdated" because every writer keeps the entry's
30//! `sourceSignature` equal to what the FILE holds: the insert lane hashes the
31//! file it read, [`UpdateComponents::run`] hashes the file it just pulled in,
32//! and an in-document part edit SAVES the part back to its `sourceKey` before
33//! stamping the signature (`panels::parts_library`'s write-through lane).
34//! Without that last one an in-context edit read as outdated against a file it
35//! was newer than — the badge meaning the opposite of what happened.
36
37use crate::panels::parts_library::{document_signature, refresh_library_entry};
38use crate::store::ModelStore;
39use brep_render::engine_state::{EngineState, NoticeSeverity};
40use crate::store::{ReadNow, Residency};
41use eframe::egui;
42use serde_json::Value;
43
44/// The shell-owned outdated-parts checker + batch refresher. One instance on
45/// the app; the constraints panel reads the count (and runs the refresh), the
46/// structure tree reads per-part flags.
47#[derive(Default)]
48pub struct UpdateComponents {
49    /// Parts-library entry names whose store content no longer matches their
50    /// `sourceSignature` — the badge count.
51    outdated: Vec<String>,
52    /// Entry names with a `sourceKey` but NO store document under it (noted,
53    /// never counted — nothing to refresh from).
54    missing: Vec<String>,
55    /// The `(applied_generation, history_revision, save_generation,
56    /// store_mutation_generation)` key the cache was computed against; `None`
57    /// forces a recompute on the next ensure.
58    checked: Option<(u64, u64, u64, u64)>,
59    /// The tab's synced signatures last passed to [`Self::ensure_current`]
60    /// ([`compute`]); [`Self::run`] judges with the same ones.
61    synced: std::collections::HashMap<String, String>,
62    /// The signatures this tab took from the files with Update since that
63    /// Open/Save, by entry. Such an entry has moved off its synced version,
64    /// but by taking the file's, not by an edit made here: a second Update
65    /// before a Save must not ask about it.
66    taken: std::collections::HashMap<String, String>,
67    /// The file version (`document_signature`) each entry's synced copy was
68    /// judged to be, since that Open/Save. An entry whose file is still that
69    /// version is not outdated whatever its copy became since: a
70    /// sub-assembly re-signed by a nested part's refresh, or edited here. (Judged by
71    /// signature and content alone, such a sub-assembly read as outdated
72    /// once its child was refreshed, and a second Update would have put the
73    /// old part back from the sub-assembly's file.)
74    seen_files: std::collections::HashMap<String, std::collections::BTreeSet<String>>,
75    /// Outdated parts that were ALSO edited in this document since it was
76    /// opened or saved: Update would discard that edit, so [`Self::run`] asks
77    /// first ([`Self::show_conflict`]).
78    asking: Option<Vec<String>>,
79    /// The answer to that question for the run it resumes: `true` takes the
80    /// files' versions, `false` keeps this document's.
81    answer: Option<bool>,
82    /// Each entry's last verdict, with exactly what it was judged from
83    /// ([`Verdict`]). The key above moves on every edit and every landed run,
84    /// but an entry whose signature, file text and synced version are all
85    /// unchanged is judged the same again: reading, parsing and hashing a
86    /// 160 KB definition per entry per recompute was ~80 ms at 500 parts.
87    verdicts: std::collections::HashMap<String, Verdict>,
88    /// How many entries were judged from scratch (the work the cache saves).
89    judged: usize,
90}
91
92/// One entry's "has this tab seen its file's version" answer, and its inputs.
93#[derive(Debug, Clone)]
94struct Verdict {
95    signature: String,
96    synced: Option<String>,
97    contents: String,
98    seen: bool,
99}
100
101impl UpdateComponents {
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Recompute the outdated/missing sets when the staleness key moved (or
107    /// after [`Self::invalidate`]); otherwise a cheap compare. The shell calls
108    /// this once per frame BEFORE the assembly panels draw.
109    ///
110    /// `synced`: each top-level entry's `sourceSignature` as of the tab's last
111    /// Open/Save ([`crate::document::Documents::synced_signatures`]); empty
112    /// when unknown, and then the entry's own signature stands alone.
113    pub fn ensure_current(
114        &mut self,
115        state: &mut EngineState,
116        model_store: &dyn ModelStore,
117        save_generation: u64,
118        synced: &std::collections::HashMap<String, String>,
119    ) {
120        if self.synced != *synced {
121            self.synced = synced.clone();
122            self.taken.clear();
123            self.seen_files.clear();
124            self.checked = None;
125        }
126        let key = (
127            state.applied_generation(),
128            state.history.revision(),
129            save_generation,
130            model_store.mutation_generation(),
131        );
132        if self.checked == Some(key) {
133            return;
134        }
135        let (outdated, missing) = compute(state, model_store, &self.synced, &mut self.verdicts, &mut self.seen_files, &mut self.judged);
136        self.outdated = outdated;
137        self.missing = missing;
138        self.checked = Some(key);
139    }
140
141    /// The parts an Update is waiting to ask about (automation readback).
142    pub fn asking(&self) -> Option<&[String]> {
143        self.asking.as_deref()
144    }
145
146    /// Answer the question [`Self::run`] asked and run the update: `true`
147    /// takes the files' versions, `false` keeps this document's edits (the
148    /// other outdated parts are updated either way).
149    pub fn answer(&mut self, state: &mut EngineState, model_store: &dyn ModelStore, take_files: bool) -> Result<usize, String> {
150        self.asking = None;
151        self.answer = Some(take_files);
152        let result = self.run(state, model_store);
153        self.answer = None;
154        result
155    }
156
157    /// The question, while one is pending: one dialog, never a block. The
158    /// default (Enter) takes the files' versions.
159    pub fn show_conflict(&mut self, ctx: &egui::Context, state: &mut EngineState, model_store: &dyn ModelStore) {
160        let Some(names) = self.asking.clone() else {
161            if crate::automation::registry::enabled() {
162                crate::automation::registry::publish("__brepUpdateComponentsHit", HIT_BLOB, "{}");
163            }
164            return;
165        };
166        let mut choice = None;
167        let mut hits = serde_json::json!({});
168        let rect = |r: egui::Rect| serde_json::json!([r.min.x, r.min.y, r.width(), r.height()]);
169        let modal = egui::Modal::new(egui::Id::new("brep-update-components-conflict")).show(ctx, |ui| {
170            ui.set_width(460.0);
171            ui.heading("Update components");
172            ui.label(format!(
173                "{} changed in its file and was also edited in this document. Updating takes the file's version and discards the edit made here.",
174                names.join(", ")
175            ));
176            ui.add_space(8.0);
177            ui.horizontal(|ui| {
178                let file = ui.button("Take the file's version");
179                hits["update:file"] = rect(file.rect);
180                if file.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
181                    choice = Some(true);
182                }
183                let mine = ui.button("Keep mine");
184                hits["update:mine"] = rect(mine.rect);
185                if mine.clicked() {
186                    choice = Some(false);
187                }
188            });
189        });
190        if crate::automation::registry::enabled() {
191            crate::automation::registry::publish("__brepUpdateComponentsHit", HIT_BLOB, &hits.to_string());
192        }
193        let choice = choice.or_else(|| modal.should_close().then_some(false));
194        if let Some(take_files) = choice {
195            if let Err(error) = self.answer(state, model_store, take_files) {
196                state.push_notice(format!("Update components: {error}"));
197            }
198        }
199    }
200
201    /// Drop the cache so the next [`Self::ensure_current`] recomputes even on
202    /// an unmoved key (after a run, or an external store change).
203    pub fn invalidate(&mut self) {
204        self.checked = None;
205    }
206
207    /// The outdated-entry count — the constraints-header badge number.
208    pub fn outdated_count(&self) -> usize {
209        self.outdated.len()
210    }
211
212    /// The outdated entry names — what the Diagram and PCB workbenches mark on
213    /// the components placed from them.
214    pub fn outdated(&self) -> &[String] {
215        &self.outdated
216    }
217
218    /// Whether `part_name`'s library entry is outdated — the structure tree's
219    /// per-node badge (every instance of the part lights).
220    pub fn is_outdated(&self, part_name: &str) -> bool {
221        self.outdated.iter().any(|part| part == part_name)
222    }
223
224    /// Entry names noted as source-less (header hover text).
225    pub fn missing(&self) -> &[String] {
226        &self.missing
227    }
228
229    /// RUN the update (the header button): recompute against the LIVE document
230    /// (never a stale cache), rewrite every outdated entry from its store
231    /// content through [`refresh_library_entry`], then reload ONCE — the
232    /// kernel self-heals every instance + re-solves, and the main-side sync
233    /// captures healed snapshots back. Per-entry failures (missing store key,
234    /// unreadable content) toast and SKIP — the batch never aborts. Returns
235    /// the number of entries refreshed.
236    pub fn run(
237        &mut self,
238        state: &mut EngineState,
239        model_store: &dyn ModelStore,
240    ) -> Result<usize, String> {
241        let (mut outdated, missing) = compute(state, model_store, &self.synced, &mut self.verdicts, &mut self.seen_files, &mut self.judged);
242        // Edited HERE as well as changed in the file: ask before discarding the
243        // edit (default: take the file's version). Edited here = the entry's
244        // signature moved off the version the tab synced with.
245        let edited_here: Vec<String> = edited_here(state, &outdated, &self.synced, &self.taken);
246        if !edited_here.is_empty() {
247            match self.answer.take() {
248                None => {
249                    self.asking = Some(edited_here);
250                    return Ok(0);
251                }
252                Some(true) => {}
253                Some(false) => outdated.retain(|name| !edited_here.contains(name)),
254            }
255        }
256        if !missing.is_empty() {
257            state.push_notice_as(NoticeSeverity::Warning, format!(
258                "Update components: no source document for {} — skipped",
259                missing.join(", ")
260            ));
261        }
262        if outdated.is_empty() {
263            self.invalidate();
264            return Ok(0);
265        }
266        // Instructions take one new revision at a time: the author approves
267        // the pending re-target first (the engine refuses it as well).
268        if state.instructions().ok().flatten().is_some_and(|block| block.retarget.is_some()) {
269            return Err("the instructions' re-target waits for approval: approve it in the Instructions pane, then update again".into());
270        }
271        let mut document = state.history_request_value();
272        let mut refreshed = 0usize;
273        let mut refreshed_parts: Vec<String> = Vec::new();
274        // Every name refreshed, a nested chain included (what `taken` records).
275        let mut refreshed_names: Vec<String> = Vec::new();
276        for part_name in &outdated {
277            let source_key = crate::document::owned_save::entry_at_chain(&document["partsLibrary"], part_name)
278                .and_then(|entry| entry["sourceKey"].as_str())
279                .unwrap_or_default()
280                .to_string();
281            let contents = match crate::store::read_now(model_store, &source_key) {
282                ReadNow::Ready(contents) => contents,
283                ReadNow::Loading => {
284                    state.push_notice_as(NoticeSeverity::Info, format!(
285                        "Update components: '{part_name}' is still loading from the store — run the update again in a moment"
286                    ));
287                    continue;
288                }
289                ReadNow::Absent => {
290                    state.push_notice_as(NoticeSeverity::Warning, format!(
291                        "Update components: '{part_name}' source '{source_key}' missing — skipped"
292                    ));
293                    continue;
294                }
295            };
296            // A nested part: every copy of its file, at every depth, takes
297            // the new version, and each sub-assembly above it is re-signed
298            // and loses its snapshot (the Work part push's refresh). Its
299            // mates are captured on the top-level occurrences holding it.
300            if let Some((top, _)) = part_name.split_once('/') {
301                let refreshed_nested = serde_json::from_str::<Value>(&contents).map_err(|error| error.to_string()).and_then(|edited| {
302                    let mut notes = Vec::new();
303                    crate::document::work_part::refresh_parts(&mut document, &[], Some(&source_key), &edited, &mut notes).map(|changed| (changed, notes))
304                });
305                match refreshed_nested {
306                    Ok((true, notes)) => {
307                        refreshed += 1;
308                        refreshed_names.push(part_name.clone());
309                        if !refreshed_parts.iter().any(|name| name == top) {
310                            refreshed_parts.push(top.to_string());
311                        }
312                        for line in notes {
313                            state.push_notice_as(NoticeSeverity::Warning, format!("Update components: '{part_name}': {line}"));
314                        }
315                    }
316                    Ok((false, _)) => {}
317                    Err(error) => state.push_notice(format!("Update components: '{part_name}': {error} — skipped")),
318                }
319                continue;
320            }
321            match refresh_library_entry(&mut document, part_name, &contents) {
322                Ok(unlinked) => {
323                    refreshed += 1;
324                    refreshed_parts.push(part_name.clone());
325                    refreshed_names.push(part_name.clone());
326                    // Per-component, not per-refresh: a sheet component the
327                    // assembly can no longer place is named and the rest of the
328                    // batch still lands.
329                    for line in unlinked {
330                        state.push_notice_as(NoticeSeverity::Warning, format!("Update components: '{part_name}': {line}"));
331                    }
332                }
333                Err(error) => state.push_notice(format!(
334                    "Update components: '{part_name}': {error} — skipped"
335                )),
336            }
337        }
338        if refreshed > 0 {
339            // The ONE reload for the whole batch (no zoom — the camera stays).
340            // The mates on the updated parts' occurrences are checked once it
341            // lands: a face the update renamed on the same plane is
342            // re-attached, anything ambiguous is held for Repair.
343            // Another open document's replay may own the thread's session.
344            state.forget_assembly_session();
345            for part_name in &refreshed_names {
346                let entry = crate::document::owned_save::entry_at_chain(&document["partsLibrary"], part_name);
347                if let Some(signature) = entry.and_then(|entry| entry["sourceSignature"].as_str()) {
348                    self.taken.insert(part_name.clone(), signature.to_string());
349                }
350            }
351            let capture = state.capture_part_edit(&state.components_of_parts(&refreshed_parts));
352            state
353                .commit_part_edit(capture, document, &refreshed_parts)
354                .map_err(|error| format!("assembly reload failed: {error}"))?;
355            state.push_notice_as(NoticeSeverity::Info, format!(
356                "Updated {refreshed} part(s) from source — instances and eCAD snapshots refreshed"
357            ));
358        }
359        self.invalidate();
360        Ok(refreshed)
361    }
362}
363
364const HIT_BLOB: &str = "Update components' question when a part was edited here AND changed in its file: update:file (default), update:mine";
365
366pub static HIT_KEYS: &[crate::automation::hit_keys::HitKeyDoc] = &[crate::automation::hit_keys::HitKeyDoc {
367    panel: "updatecomponents",
368    prefix: "update:",
369    meaning: "Update components' question for a part edited here AND changed in its file: update:file (take the file's version, the default), update:mine (keep this document's edit)",
370    command: None,
371}];
372
373/// Of `outdated`, the parts this document ALSO edited since its last
374/// Open/Save: the entry's signature is no longer the one it synced with, nor
375/// one an Update took from the file (`taken`).
376fn edited_here(
377    state: &EngineState,
378    outdated: &[String],
379    synced: &std::collections::HashMap<String, String>,
380    taken: &std::collections::HashMap<String, String>,
381) -> Vec<String> {
382    let library = state.history.parts_library();
383    outdated
384        .iter()
385        .filter(|name| {
386            synced.get(*name).is_some_and(|synced| {
387                crate::document::owned_save::entry_at_chain(library, name)
388                    .and_then(|entry| entry["sourceSignature"].as_str())
389                    .is_some_and(|now| now != synced && taken.get(*name).is_none_or(|taken| taken != now))
390            })
391        })
392        .cloned()
393        .collect()
394}
395
396/// Whether the copy `document` of the PLM revision `source_key` is the very
397/// bytes the PLM stores for it, as the store's index lists them: then it is up
398/// to date, judged without reading the revision (the open's cached-copy check
399/// takes such a copy for its revision the same way). Reading every source of
400/// a 500-part PLM assembly cost 36 requests on every open.
401fn copy_is_revision(store: &dyn ModelStore, source_key: &str, document: &Value) -> bool {
402    let Some((part, revision)) = crate::plm::bom::revision_of_document(source_key) else { return false };
403    document["features"].is_array()
404        && store
405            .plm_revision(&crate::plm::identity::document_key(&part, &revision))
406            .is_some_and(|row| crate::plm::content_hash(document.to_string().as_bytes()) == row.content_hash)
407}
408
409/// The signature comparison over the LIVE document's `partsLibrary` block →
410/// `(outdated, missing)` entry-name sets. Componentless documents short-circuit
411/// to empty (zero cost for modeling files).
412///
413/// OUTDATED = the file holds a version this tab has never seen. The tab has
414/// seen two: the one it synced with at its last Open/Save (`synced`) and its
415/// own current copy. An edit made in the assembly (a Work part, a BOM cell, a
416/// live link from the part's open tab) re-stamps the entry's signature and
417/// leaves the file alone; judged against the entry's signature alone it read
418/// as "outdated" and offered an Update that would REVERT it.
419fn compute(
420    state: &mut EngineState,
421    model_store: &dyn ModelStore,
422    synced: &std::collections::HashMap<String, String>,
423    verdicts: &mut std::collections::HashMap<String, Verdict>,
424    seen_files: &mut std::collections::HashMap<String, std::collections::BTreeSet<String>>,
425    judged: &mut usize,
426) -> (Vec<String>, Vec<String>) {
427    let mut outdated = Vec::new();
428    let mut missing = Vec::new();
429    if !state.history_has_assembly() {
430        return (outdated, missing);
431    }
432    // The document's partsLibrary block mirrors the kernel store (heals + GC
433    // included); `apply_run_output` installs and mirrors the store of any run
434    // that moved it, so the block is already current for this generation.
435    // Read in place: serializing the whole document (and its thumbnail) to
436    // parse it back cost ~40 ms per recompute at 500 parts.
437    let history = &state.history;
438    let Some(library) = history.parts_library().as_object() else {
439        return (outdated, missing);
440    };
441    let sheets = [history.diagram_block(), history.pcb_block()];
442    for (part_name, entry) in library {
443        let Some(source_key) = entry
444            .get("sourceKey")
445            .and_then(Value::as_str)
446            .filter(|key| !key.is_empty())
447        else {
448            continue; // embedded-only part: no source to compare against
449        };
450        if sheets.iter().all(Option::is_none) && copy_is_revision(model_store, source_key, &entry["document"]) {
451            continue;
452        }
453        match crate::store::read_now(model_store, source_key) {
454            // Listed but not loaded yet (an index-hydrated store, S0): neither
455            // missing nor outdated — there is nothing to judge until the bytes
456            // land, and their landing moves the store generation this cache is
457            // keyed on, so the entry is judged then.
458            ReadNow::Loading => {}
459            ReadNow::Absent => missing.push(part_name.clone()),
460            ReadNow::Ready(contents) => {
461                let signature = entry
462                    .get("sourceSignature")
463                    .and_then(Value::as_str)
464                    .unwrap_or_default();
465                // A component's version belongs to the DEVICE it was placed
466                // from, and a nested device's key is a key of ITS OWN parent's
467                // library — the same spelling in a different namespace. Judging
468                // one against a top-level entry read it as stale against a part
469                // it was never placed from, and NOTHING could clear the badge:
470                // `parts_library::refresh_library_entry` binds by occurrence
471                // CHAIN and correctly declines to touch a device that is not
472                // this entry's. So a chain (`ACOMP9:ACOMP1`) is not this
473                // entry's to judge. A top-level occurrence is, and so is a
474                // legacy component with no occurrence at all.
475                let stale_sheet = sheets.iter().flatten().any(|block| {
476                    block["components"].as_array().is_some_and(|components| {
477                        components.iter().any(|component| component["part"]["key"].as_str() == Some(part_name.as_str())
478                            && !component["part"]["instance"].as_str().is_some_and(|id| id.contains(':'))
479                            && component["part"]["signature"].as_str() != Some(signature))
480                    })
481                });
482                let synced_now = synced.get(part_name).cloned();
483                let cached = verdicts.get(part_name).filter(|v| {
484                    v.signature == signature && v.synced == synced_now && v.contents == contents
485                });
486                let seen = match cached {
487                    Some(verdict) => verdict.seen,
488                    None => {
489                        *judged += 1;
490                        let on_disk = document_signature(&contents);
491                        let seen = on_disk == signature
492                            || synced_now.as_ref().is_some_and(|synced| *synced == on_disk)
493                            || seen_files.get(part_name).is_some_and(|files| files.contains(&on_disk))
494                            || serde_json::from_str::<Value>(&contents).is_ok_and(|file| {
495                                crate::document::owned_save::source_hash(&file)
496                                    == crate::document::owned_save::source_hash(&entry["document"])
497                            });
498                        // Remembered only while the copy is still the synced one:
499                        // the file version the baseline is. A version an
500                        // Update took is not (undoing that Update makes the
501                        // entry outdated again).
502                        if seen && synced_now.as_deref() == Some(signature) {
503                            seen_files.entry(part_name.clone()).or_default().insert(on_disk);
504                        }
505                        verdicts.insert(
506                            part_name.clone(),
507                            Verdict { signature: signature.to_string(), synced: synced_now, contents, seen },
508                        );
509                        seen
510                    }
511                };
512                if !seen || stale_sheet {
513                    outdated.push(part_name.clone());
514                }
515            }
516        }
517    }
518    // A part that exists only INSIDE a sub-assembly has a file too: judged
519    // the same way, by its chain (`module-1/part-3`). A sub-assembly's own
520    // edit of it (a Work part push) stays "seen" through the synced version.
521    // (Only top-level entries were judged, so a changed nested part's file
522    // read as up to date and Update left the old geometry in place.)
523    for (chain, entry) in crate::document::owned_save::nested_entries(history.parts_library()) {
524        let Some(source_key) = entry.get("sourceKey").and_then(Value::as_str).filter(|key| !key.is_empty()) else {
525            continue;
526        };
527        if copy_is_revision(model_store, source_key, &entry["document"]) {
528            continue;
529        }
530        let ReadNow::Ready(contents) = crate::store::read_now(model_store, source_key) else {
531            continue; // a nested source that is absent or loading is not reported
532        };
533        let signature = entry.get("sourceSignature").and_then(Value::as_str).unwrap_or_default();
534        let synced_now = synced.get(&chain).cloned();
535        let cached = verdicts
536            .get(&chain)
537            .filter(|v| v.signature == signature && v.synced == synced_now && v.contents == contents);
538        let seen = match cached {
539            Some(verdict) => verdict.seen,
540            None => {
541                *judged += 1;
542                let on_disk = document_signature(&contents);
543                let seen = on_disk == signature
544                    || synced_now.as_ref().is_some_and(|synced| *synced == on_disk)
545                    || seen_files.get(&chain).is_some_and(|files| files.contains(&on_disk))
546                    || serde_json::from_str::<Value>(&contents).is_ok_and(|file| {
547                        crate::document::owned_save::source_hash(&file)
548                            == crate::document::owned_save::source_hash(&entry["document"])
549                    });
550                if seen && synced_now.as_deref() == Some(signature) {
551                    seen_files.entry(chain.clone()).or_default().insert(on_disk);
552                }
553                verdicts.insert(chain.clone(), Verdict { signature: signature.to_string(), synced: synced_now, contents, seen });
554                seen
555            }
556        };
557        // One file placed at several depths is one update.
558        if !seen && !outdated.iter().any(|name| {
559            crate::document::owned_save::entry_at_chain(history.parts_library(), name)
560                .is_some_and(|other| other["sourceKey"].as_str() == Some(source_key))
561        }) {
562            outdated.push(chain);
563        }
564    }
565    (outdated, missing)
566}
567