Skip to main content

brep_app/panels/
plm_import.rs

1//! Adoption (S13) and the uses-list re-point (S5), as the user meets them.
2//!
3//! * **Import a folder** ([`PlmImportPanel`]): read every model document under
4//!   an explorer folder, show what the import will do (parts by class, the
5//!   assemblies whose uses lists will be published, the documents it refuses,
6//!   and every `sourceKey` pointing outside the folder), then run it against
7//!   the PLM, with progress and **Stop**. The ledger is saved under
8//!   `@plm_import` for this server and folder after every step, so a stopped
9//!   or crashed import resumes where it was, and an import of a finished
10//!   folder sends nothing. The folder is only ever read.
11//! * **Re-point** ([`RepointPrompt`]): on opening an assembly revision whose
12//!   uses list no longer matches its document (someone ran Replace everywhere),
13//!   offer to move the occurrences to what the list names. Otherwise the next
14//!   save would republish the list from the document and quietly undo the
15//!   swap.
16//!
17//! Both are PLM-only: constructed with a signed-in client, never in a file
18//! session.
19
20use crate::panels::plm_parts::Pending;
21use crate::plm::adoption::{
22    plan_adoption, run_import, walk_tree, AdoptionPlan, ImportLedgers, ImportNumbering, ImportReport, Ledger,
23    SourceDocument,
24};
25use crate::plm::client::PlmClient;
26use crate::plm::uses::{Disagreement, Repoint};
27use crate::store::ModelStore;
28use eframe::egui;
29use std::cell::{Cell, RefCell};
30use std::collections::HashMap;
31use std::rc::Rc;
32use std::task::Waker;
33
34/// What the folder holds and what the import will do with it.
35pub struct Planned {
36    pub folder: String,
37    pub documents: Vec<SourceDocument>,
38    pub plan: AdoptionPlan,
39    /// Every `sourceKey` in the folder's documents, spelled the explorer's
40    /// way, worked out once so the running import needs no store.
41    canonical: HashMap<String, String>,
42}
43
44/// A run in flight: its answer, the ledger as it moves, and Stop.
45struct Running {
46    answer: Pending<ImportReport>,
47    ledger: Rc<RefCell<Ledger>>,
48    stop: Rc<Cell<bool>>,
49    saved: Ledger,
50}
51
52/// The import panel.
53pub struct PlmImportPanel {
54    pub folder: String,
55    pub numbering: ImportNumbering,
56    pub planned: Option<Planned>,
57    pub report: Option<ImportReport>,
58    pub problem: Option<String>,
59    running: Option<Running>,
60    /// What `draw` was asked for, done by the next `sync`.
61    asked: Option<Ask>,
62    /// The ledger to show: the run's as it moves, else what the store holds
63    /// for this folder and server.
64    shown: Option<Ledger>,
65    /// Whether this session has local folders to read (see [`Self::sync`]).
66    local: bool,
67    pub hits: HashMap<String, egui::Rect>,
68}
69
70/// A click `draw` records for `sync`.
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72enum Ask {
73    Plan,
74    Start,
75    Stop,
76}
77
78impl Default for PlmImportPanel {
79    fn default() -> Self {
80        Self {
81            folder: String::new(),
82            numbering: ImportNumbering { part_type: "component".into(), number_from_name: false },
83            planned: None,
84            report: None,
85            problem: None,
86            running: None,
87            asked: None,
88            shown: None,
89            local: true,
90            hits: HashMap::new(),
91        }
92    }
93}
94
95impl PlmImportPanel {
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    pub fn running(&self) -> bool {
101        self.running.is_some()
102    }
103
104    /// Read the folder and plan. Reads only.
105    pub fn plan(&mut self, store: &dyn ModelStore) {
106        self.report = None;
107        self.problem = None;
108        match walk_tree(store, &self.folder) {
109            Ok(documents) => {
110                let mut canonical = HashMap::new();
111                for document in &documents {
112                    let Ok(value) = serde_json::from_str::<serde_json::Value>(&document.contents) else {
113                        continue;
114                    };
115                    collect_keys(&value, &mut |key| {
116                        canonical.entry(key.to_string()).or_insert_with(|| store.canonical_identity(key));
117                    });
118                }
119                let lookup = |key: &str| canonical.get(key).cloned().unwrap_or_else(|| key.to_string());
120                let plan = plan_adoption(&documents, &lookup);
121                self.planned = Some(Planned { folder: self.folder.clone(), documents, plan, canonical });
122            }
123            Err(problem) => {
124                self.planned = None;
125                self.problem = Some(problem);
126            }
127        }
128    }
129
130    /// Start (or resume) the import of the planned folder into `server`.
131    pub fn start(&mut self, store: &dyn ModelStore, client: Rc<PlmClient>, server: &str) {
132        let Some(planned) = &self.planned else {
133            return;
134        };
135        let ledger = ImportLedgers::load(store).ledger(server, &planned.folder);
136        let shared = Rc::new(RefCell::new(ledger.clone()));
137        let stop = Rc::new(Cell::new(false));
138        let (plan, documents, canonical) = (planned.plan.clone(), planned.documents.clone(), planned.canonical.clone());
139        let numbering = self.numbering.clone();
140        let (progress, stopping) = (shared.clone(), stop.clone());
141        let future = async move {
142            let lookup = |key: &str| canonical.get(key).cloned().unwrap_or_else(|| key.to_string());
143            let mut ledger = ledger;
144            let report = run_import(&client, &plan, &documents, &mut ledger, &lookup, &numbering, &mut |moved| {
145                *progress.borrow_mut() = moved.clone();
146                !stopping.get()
147            })
148            .await;
149            Ok(report)
150        };
151        self.report = None;
152        self.running = Some(Running {
153            answer: Pending::new(Box::pin(future)),
154            ledger: shared,
155            stop,
156            saved: Ledger::default(),
157        });
158    }
159
160    /// Ask the run to stop after the step in flight.
161    pub fn stop(&mut self) {
162        if let Some(running) = &self.running {
163            running.stop.set(true);
164        }
165    }
166
167    /// Fold in the run's progress: save a ledger that moved, take the report
168    /// when it ends.
169    pub fn poll(&mut self, store: &dyn ModelStore, server: &str, waker: &Waker) {
170        let Some(running) = &mut self.running else {
171            return;
172        };
173        let answer = running.answer.poll(waker);
174        let now = running.ledger.borrow().clone();
175        if now != running.saved {
176            if let Some(planned) = &self.planned {
177                let mut ledgers = ImportLedgers::load(store);
178                if let Err(problem) = ledgers.save(store, server, &planned.folder, &now) {
179                    self.problem = Some(format!("the import's progress could not be saved: {problem}"));
180                }
181            }
182            running.saved = now;
183        }
184        if let Some(answer) = answer {
185            self.running = None;
186            match answer {
187                Ok(report) => self.report = Some(report),
188                Err(problem) => self.problem = Some(problem),
189            }
190        }
191    }
192
193    /// The store's half of a frame, for a host whose drawing has no store:
194    /// seed the folder from the explorer, act on what `draw` was asked last
195    /// frame, fold in the run's progress (saving the ledger), and refresh the
196    /// shown ledger.
197    ///
198    /// `files` is the store the folder is READ from — this machine's own file
199    /// store, never the PLM's (`ModelStore::local_files` in a PLM session);
200    /// `store` holds the ledger. `None`: this session has no local folders
201    /// (the browser), and the panel says so.
202    pub fn sync(&mut self, store: &dyn ModelStore, files: Option<&dyn ModelStore>, client: Option<Rc<PlmClient>>, server: &str, waker: &Waker) {
203        self.local = files.is_some();
204        if self.folder.is_empty() {
205            if let Some(files) = files {
206                self.folder = files.browser_location();
207            }
208        }
209        match self.asked.take() {
210            Some(Ask::Plan) => match files {
211                Some(files) => self.plan(files),
212                None => self.problem = Some("importing a folder needs the desktop app: this session has no local folders".into()),
213            },
214            Some(Ask::Start) => {
215                if let Some(client) = client {
216                    self.start(store, client, server);
217                }
218            }
219            Some(Ask::Stop) => self.stop(),
220            None => {}
221        }
222        self.poll(store, server, waker);
223        self.shown = match (&self.running, &self.planned) {
224            (Some(running), _) => Some(running.ledger.borrow().clone()),
225            (None, Some(planned)) => Some(ImportLedgers::load(store).ledger(server, &planned.folder)),
226            (None, None) => None,
227        };
228    }
229
230    /// `{folder, documents, parts, assemblies, refused, external, running,
231    /// report, problem}` — for scripts, through the PLM pane's state.
232    pub fn state_json(&self) -> serde_json::Value {
233        let plan = self.planned.as_ref().map(|p| &p.plan);
234        serde_json::json!({
235            "folder": self.folder,
236            "local": self.local,
237            "documents": self.planned.as_ref().map_or(0, |p| p.documents.len()),
238            "parts": plan.map_or(0, |p| p.parts.len()),
239            "assemblies": plan.map_or(0, |p| p.parts.iter().filter(|x| x.assembly).count()),
240            "refused": plan.map_or(0, |p| p.refused.len()),
241            "external": plan.map_or(0, |p| p.external.len()),
242            "running": self.running(),
243            "report": self.report.as_ref().map(|r| serde_json::json!({
244                "minted": r.minted, "uploaded": r.uploaded, "published": r.published,
245                "refused": r.refused.iter().map(|(d, why)| format!("{d}: {why}")).collect::<Vec<_>>(),
246            })),
247            "problem": self.problem,
248        })
249    }
250
251    /// Draw the panel from what `sync` left, recording what the user asks
252    /// for; the next `sync` does it. `can_import` is whether a PLM is signed
253    /// in — without one the panel says so and imports nothing.
254    pub fn draw(&mut self, ui: &mut egui::Ui, can_import: bool) {
255        self.hits.clear();
256        if !self.local {
257            ui.weak("Importing a folder of documents runs in the desktop app: this session has no local folders.");
258            return;
259        }
260        ui.horizontal(|ui| {
261            ui.label("Folder");
262            let field = ui.add_enabled(!self.running(), egui::TextEdit::singleline(&mut self.folder));
263            self.hits.insert("plm_import:folder".into(), field.rect);
264            let plan = ui.add_enabled(!self.running(), egui::Button::new("Read folder"));
265            self.hits.insert("plm_import:plan".into(), plan.rect);
266            if plan.clicked() {
267                self.asked = Some(Ask::Plan);
268            }
269        });
270        ui.horizontal(|ui| {
271            ui.label("Part type");
272            ui.add_enabled(!self.running(), egui::TextEdit::singleline(&mut self.numbering.part_type).desired_width(120.0));
273            ui.add_enabled(
274                !self.running(),
275                egui::Checkbox::new(&mut self.numbering.number_from_name, "number each part by its file name"),
276            );
277        });
278        if let Some(planned) = &self.planned {
279            let plan = &planned.plan;
280            let assemblies = plan.parts.iter().filter(|p| p.assembly).count();
281            ui.label(format!(
282                "{} document{} → {} part{}, {} with a uses list",
283                planned.documents.len(),
284                plural(planned.documents.len()),
285                plan.parts.len(),
286                plural(plan.parts.len()),
287                assemblies
288            ));
289            for (document, reason) in &plan.refused {
290                ui.colored_label(ui.visuals().warn_fg_color, format!("not imported: {document} — {reason}"));
291            }
292            for (document, key) in &plan.external {
293                ui.colored_label(
294                    ui.visuals().warn_fg_color,
295                    format!("{document} uses '{key}', which is not in this folder: its uses list will be refused"),
296                );
297            }
298            if let Some(ledger) = &self.shown {
299                let minted = ledger.entries.len();
300                let uploaded = ledger.entries.values().filter(|e| e.uploaded).count();
301                let published = ledger.entries.values().filter(|e| e.published).count();
302                ui.weak(format!(
303                    "on this server: {minted}/{} minted, {uploaded}/{} uploaded, {published}/{assemblies} uses lists",
304                    plan.parts.len(),
305                    plan.parts.len()
306                ));
307            }
308        }
309        ui.horizontal(|ui| match (can_import, self.running()) {
310            (_, true) => {
311                ui.spinner();
312                let stop = ui.button("Stop");
313                self.hits.insert("plm_import:stop".into(), stop.rect);
314                if stop.clicked() {
315                    self.asked = Some(Ask::Stop);
316                }
317            }
318            (true, false) => {
319                let run = ui.add_enabled(self.planned.is_some(), egui::Button::new("Import"));
320                self.hits.insert("plm_import:run".into(), run.rect);
321                if run.clicked() {
322                    self.asked = Some(Ask::Start);
323                }
324            }
325            (false, false) => {
326                ui.weak("Sign in to a PLM to import.");
327            }
328        });
329        if let Some(report) = &self.report {
330            ui.label(format!(
331                "minted {}, uploaded {}, published {}",
332                report.minted, report.uploaded, report.published
333            ));
334            for (document, sentence) in &report.refused {
335                ui.colored_label(ui.visuals().error_fg_color, format!("{document}: {sentence}"));
336            }
337        }
338        if let Some(problem) = &self.problem {
339            ui.colored_label(ui.visuals().error_fg_color, problem);
340        }
341    }
342
343    /// Both halves in one call, for a caller that has the store while it
344    /// draws. The second `sync` acts on this frame's click at once, and polls
345    /// a run started THIS frame — only a poll registers its waker.
346    pub fn show(&mut self, ui: &mut egui::Ui, store: &dyn ModelStore, client: Option<Rc<PlmClient>>, server: &str) {
347        let waker = repaint_waker(ui.ctx());
348        self.sync(store, Some(store), client.clone(), server, &waker);
349        self.draw(ui, client.is_some());
350        self.sync(store, Some(store), client, server, &waker);
351    }
352}
353
354fn collect_keys(document: &serde_json::Value, found: &mut dyn FnMut(&str)) {
355    let Some(library) = document.get("partsLibrary").and_then(serde_json::Value::as_object) else {
356        return;
357    };
358    for entry in library.values() {
359        if let Some(key) = entry.get("sourceKey").and_then(serde_json::Value::as_str) {
360            if !key.is_empty() {
361                found(key);
362            }
363        }
364        if let Some(nested) = entry.get("document") {
365            collect_keys(nested, found);
366        }
367    }
368}
369
370fn plural(count: usize) -> &'static str {
371    if count == 1 {
372        ""
373    } else {
374        "s"
375    }
376}
377
378fn repaint_waker(ctx: &egui::Context) -> Waker {
379    struct Repaint(egui::Context);
380    impl std::task::Wake for Repaint {
381        fn wake(self: std::sync::Arc<Self>) {
382            self.0.request_repaint();
383        }
384    }
385    Waker::from(std::sync::Arc::new(Repaint(ctx.clone())))
386}
387
388// --- the re-point prompt --------------------------------------------------------------
389
390/// The answer to the re-point prompt.
391#[derive(Clone, Debug, PartialEq)]
392pub enum RepointChoice {
393    /// Move these occurrences (`plm::uses::repoint` each, then Update
394    /// Components brings the new parts in).
395    Repoint(Vec<Repoint>),
396    /// Keep the document as it is. Its next save republishes the list from
397    /// the document, which undoes the swap — the prompt says so.
398    KeepDocument,
399}
400
401/// The prompt shown on opening an assembly whose uses list and document
402/// disagree. `names` turns a part id into what a person reads (its number).
403pub struct RepointPrompt;
404
405impl RepointPrompt {
406    pub fn show(
407        ui: &mut egui::Ui,
408        found: &Disagreement,
409        names: &dyn Fn(&str) -> String,
410        hits: &mut HashMap<String, egui::Rect>,
411    ) -> Option<RepointChoice> {
412        ui.label("This assembly's parts list on the PLM no longer matches the document:");
413        for repoint in &found.repoints {
414            ui.label(format!(
415                "•  {} ({}) → {}",
416                names(&repoint.from.0),
417                repoint.occurrences.join(", "),
418                names(&repoint.to.0)
419            ));
420        }
421        for placed in &found.unlisted {
422            ui.weak(format!("•  placed here but not on the list: {} ×{}", names(&placed.line.part), placed.line.quantity));
423        }
424        for listed in &found.unplaced {
425            ui.weak(format!("•  on the list but not placed: {} ×{}", names(&listed.part), listed.quantity));
426        }
427        let mut choice = None;
428        ui.horizontal(|ui| {
429            if !found.repoints.is_empty() {
430                let button = ui.button("Re-point to match the PLM");
431                hits.insert("plm_import:repoint:apply".into(), button.rect);
432                if button.clicked() {
433                    choice = Some(RepointChoice::Repoint(found.repoints.clone()));
434                }
435            }
436            let keep = ui.button("Keep the document");
437            hits.insert("plm_import:repoint:keep".into(), keep.rect);
438            if keep.clicked() {
439                choice = Some(RepointChoice::KeepDocument);
440            }
441        });
442        ui.weak("Keeping it means the next save publishes the parts list from the document again.");
443        choice
444    }
445}
446
447// --- the check on open ------------------------------------------------------------------
448
449/// The store key a document name carries on a PLM store.
450pub fn revision_key_of(name: &str) -> Option<String> {
451    crate::plm::uses::revision_key_in(name)
452}
453
454/// S5's check on open: every PLM assembly document is compared once with
455/// its revision's uses list, and a disagreement (Replace everywhere swapped a
456/// child in the LIST) is offered to the user as a re-point before their next
457/// save would republish the list from the document and undo the swap.
458#[derive(Default)]
459pub struct RepointCheck {
460    /// Documents already checked (or being checked), by document id.
461    checked: std::collections::HashSet<u64>,
462    pending: HashMap<u64, Pending<Disagreement>>,
463    /// What each document disagrees about, until the user answers.
464    found: HashMap<u64, Disagreement>,
465    pub hits: HashMap<String, egui::Rect>,
466}
467
468impl RepointCheck {
469    pub fn new() -> Self {
470        Self::default()
471    }
472
473    /// Once a frame: start a check for each newly opened PLM assembly, and
474    /// fold in the answers. `waker` repaints.
475    pub fn sync(&mut self, client: &Rc<PlmClient>, docs: &crate::document::Documents, waker: &Waker) {
476        let open: Vec<u64> = docs.iter().map(|d| d.id()).collect();
477        self.checked.retain(|id| open.contains(id));
478        self.pending.retain(|id, _| open.contains(id));
479        self.found.retain(|id, _| open.contains(id));
480        for doc in docs.iter() {
481            if self.checked.contains(&doc.id()) {
482                continue;
483            }
484            let Some(key) = doc.name().and_then(revision_key_of) else {
485                continue;
486            };
487            self.checked.insert(doc.id());
488            let Ok(document) = serde_json::from_str::<serde_json::Value>(&doc.engine.history_request_json()) else {
489                continue;
490            };
491            if !crate::plm::uses::publishes_on_save(&document) {
492                continue;
493            }
494            let client = client.clone();
495            self.pending.insert(
496                doc.id(),
497                Pending::new(Box::pin(async move { crate::plm::uses::check_on_open(&client, &key, &document).await })),
498            );
499        }
500        let answered: Vec<(u64, Result<Disagreement, String>)> = self
501            .pending
502            .iter_mut()
503            .filter_map(|(id, pending)| pending.poll(waker).map(|answer| (*id, answer)))
504            .collect();
505        for (id, answer) in answered {
506            self.pending.remove(&id);
507            // A failed check is not a disagreement: nothing is offered, and
508            // the save republishes as it always does.
509            if let Ok(found) = answer {
510                if !found.is_empty() {
511                    self.found.insert(id, found);
512                }
513            }
514        }
515    }
516
517    pub fn busy(&self) -> bool {
518        !self.pending.is_empty()
519    }
520
521    /// What document `id` disagrees about, if anything is still unanswered.
522    pub fn disagreement(&self, id: u64) -> Option<&Disagreement> {
523        self.found.get(&id)
524    }
525
526    /// Apply the user's answer to document `id`. A re-point edits the
527    /// document (one undoable edit, dirty until saved; the save republishes
528    /// the list, now matching the server's) — Update Components then brings
529    /// the new parts in.
530    pub fn answer(&mut self, docs: &mut crate::document::Documents, id: u64, choice: RepointChoice) -> Result<(), String> {
531        self.found.remove(&id);
532        let RepointChoice::Repoint(repoints) = choice else {
533            return Ok(());
534        };
535        let Some(doc) = docs.iter_mut().find(|d| d.id() == id) else {
536            return Ok(());
537        };
538        let mut document: serde_json::Value =
539            serde_json::from_str(&doc.engine.history_request_json()).map_err(|e| e.to_string())?;
540        for repoint in &repoints {
541            crate::plm::uses::repoint(&mut document, repoint, &crate::plm::uses::plm_identity);
542        }
543        doc.engine.edit_document_json(&document.to_string()).map(|_| ())
544    }
545
546    /// Draw the prompt for the active document, if it has one; apply the
547    /// answer the frame it is given. Part ids are shown as given (the host
548    /// may pass a namer that turns them into numbers).
549    pub fn ui(&mut self, ui: &mut egui::Ui, docs: &mut crate::document::Documents, names: &dyn Fn(&str) -> String) -> Result<(), String> {
550        self.hits.clear();
551        let id = docs.active().id();
552        let Some(found) = self.found.get(&id).cloned() else {
553            return Ok(());
554        };
555        match RepointPrompt::show(ui, &found, names, &mut self.hits) {
556            Some(choice) => self.answer(docs, id, choice),
557            None => Ok(()),
558        }
559    }
560}
561
562// --- cached copies, refreshed on open ----------------------------------------------------
563
564/// The other check on open: an assembly's embedded copies of PLM parts are a
565/// cache of their revisions ([`crate::plm::cached_copies`]). Each PLM document
566/// is compared once, when it opens; the copies older than their revisions are
567/// replaced as part of the open ([`crate::document::Document::absorb_open_write`]:
568/// not an unsaved change), with one notice naming them.
569///
570/// And while it is open: when the change feed names a revision an open
571/// document embeds, at any depth ([`Self::moved`]), it is compared again. The
572/// copies the user has not edited follow their revisions, through the chain
573/// (a part saved elsewhere refreshes the sub that holds it), in the document
574/// and its clean baseline alike, so the refresh is no unsaved change of its
575/// own. A copy the user edited is never replaced unasked: the pane asks, Keep
576/// my edits (the default) or Take their version. Never while a write of ours
577/// is in flight: the feed names our own keys before their writes land.
578#[derive(Default)]
579pub struct CachedCopyCheck {
580    checked: std::collections::HashSet<u64>,
581    /// Each read in flight: the revisions read, or taken from unchanged copies.
582    pending: HashMap<u64, Pending<HashMap<String, serde_json::Value>>>,
583    /// The revisions each open took from its copies instead of reading
584    /// (state, for automation).
585    unread: HashMap<u64, usize>,
586    /// The copies each document had refreshed, by document id.
587    refreshed: HashMap<u64, Vec<String>>,
588    /// Documents due a live check, with the revisions the feed named
589    /// (empty: it could not say which).
590    due: HashMap<u64, Vec<String>>,
591    /// Documents whose read in flight is a live check, as `due`.
592    live: HashMap<u64, Vec<String>>,
593    /// Edited copies whose revisions moved, while the pane asks: the copies'
594    /// paths and the revisions read.
595    asking: HashMap<u64, (Vec<String>, HashMap<String, serde_json::Value>)>,
596    /// Revisions whose change the user chose not to take, per document: the
597    /// revision's text when they did, so only a further change asks again.
598    kept: HashMap<u64, HashMap<String, String>>,
599    /// Live refreshes since the session began (state, for automation).
600    updated: u64,
601    /// Documents re-checked after a reload ([`Self::recheck`]): their open
602    /// already said what it refreshed and what is kept, so this check is quiet.
603    quiet: std::collections::HashSet<u64>,
604}
605
606impl CachedCopyCheck {
607    pub fn new() -> Self {
608        Self::default()
609    }
610
611    /// Once a frame: start a read of the revisions each newly opened PLM
612    /// document's copies name, and apply the answers.
613    pub fn sync(&mut self, client: &Rc<PlmClient>, store: &dyn ModelStore, docs: &mut crate::document::Documents, waker: &Waker, writing: bool) {
614        let open: Vec<u64> = docs.iter().map(|d| d.id()).collect();
615        self.checked.retain(|id| open.contains(id));
616        self.pending.retain(|id, _| open.contains(id));
617        self.unread.retain(|id, _| open.contains(id));
618        self.refreshed.retain(|id, _| open.contains(id));
619        self.due.retain(|id, _| open.contains(id));
620        self.live.retain(|id, _| open.contains(id));
621        self.asking.retain(|id, _| open.contains(id));
622        self.kept.retain(|id, _| open.contains(id));
623        self.quiet.retain(|id| open.contains(id));
624        if !writing {
625            for (id, keys) in std::mem::take(&mut self.due) {
626                if self.pending.contains_key(&id) {
627                    self.due.insert(id, keys);
628                } else {
629                    self.checked.remove(&id);
630                    self.live.insert(id, keys);
631                }
632            }
633        }
634        for doc in docs.iter() {
635            if self.checked.contains(&doc.id()) || doc.name().and_then(revision_key_of).is_none() {
636                continue;
637            }
638            self.checked.insert(doc.id());
639            // Restored from crash recovery: its copies hold the user's unsaved
640            // edits, which a refresh from the revisions would throw away (and
641            // take into the clean baseline, so the tab read saved). The Save
642            // writes them; the follower still reports what others change.
643            if doc.is_recovered() {
644                continue;
645            }
646            let Ok(document) = serde_json::from_str::<serde_json::Value>(&doc.engine.history_request_json()) else {
647                continue;
648            };
649            if !document["partsLibrary"].as_object().is_some_and(|l| !l.is_empty()) {
650                continue;
651            }
652            // Copies that are their revisions' stored bytes, as the index
653            // lists them, stand in for them. Not those of the revisions the feed just
654            // named: the held index may not have their new rows yet.
655            let mut unchanged = crate::plm::cached_copies::unchanged(&document, &|key| {
656                store.plm_revision(key).map(|entry| entry.content_hash)
657            });
658            // Except the ones the feed named for our own save: the index row
659            // holds what the server answered for our write, so a copy with
660            // those bytes is the revision (another writer since would have
661            // given the row a newer hash, and it is read).
662            match self.live.get(&doc.id()) {
663                Some(named) if named.is_empty() => unchanged.clear(),
664                Some(named) => unchanged.retain(|key, _| !named.contains(key) || store.plm_written_by_us(key)),
665                None => {}
666            }
667            self.unread.insert(doc.id(), unchanged.len());
668            let client = client.clone();
669            self.pending.insert(
670                doc.id(),
671                Pending::new(Box::pin(async move { Ok(crate::plm::cached_copies::fetch_changed(&client, &document, unchanged).await) })),
672            );
673        }
674        let answered: Vec<(u64, Result<HashMap<String, serde_json::Value>, String>)> = self
675            .pending
676            .iter_mut()
677            .filter_map(|(id, pending)| pending.poll(waker).map(|answer| (*id, answer)))
678            .collect();
679        for (id, answer) in answered {
680            self.pending.remove(&id);
681            if let Some(moved) = self.live.remove(&id) {
682                if writing {
683                    // Read while a write of ours was going out: it may predate it.
684                    self.due.insert(id, moved);
685                    continue;
686                }
687                if let (Ok(sources), Some(doc)) = (answer, docs.iter_mut().find(|d| d.id() == id)) {
688                    if let Some(paths) = self.follow(doc, &sources) {
689                        self.asking.insert(id, (paths, sources));
690                    }
691                }
692                continue;
693            }
694            let (Ok(sources), Some(doc)) = (answer, docs.iter_mut().find(|d| d.id() == id)) else {
695                continue;
696            };
697            // Refreshed against the document as it is NOW: the user may have
698            // edited it while the revisions were being read.
699            let Ok(mut document) = serde_json::from_str::<serde_json::Value>(&doc.engine.history_request_json()) else {
700                continue;
701            };
702            let before = document.clone();
703            let quiet = self.quiet.remove(&id);
704            // Copies a Save could not write keep the user's change: say so, and
705            // what to do, every time the document opens.
706            for (key, why) in crate::document::owned_save::unsaved_edits(&document).into_iter().filter(|_| !quiet) {
707                let name = crate::document::owned_save::first_copy(&document, &key, &mut Vec::new())
708                    .map_or_else(|| key.clone(), |(names, _)| names.join("/"));
709                doc.engine.push_notice(format!(
710                    "{name} has changes that are not saved to its PLM item yet ({why}): {}. They are kept in this assembly until then",
711                    crate::plm::cached_copies::way_forward(&why)
712                ));
713            }
714            let replaced = crate::plm::cached_copies::refresh(&mut document, &sources);
715            if replaced.is_empty() {
716                continue;
717            }
718            let key = doc.edit_key();
719            match doc.engine.edit_document_json(&document.to_string()) {
720                // Taken as part of the open, not as an edit: the refresh adds
721                // no unsaved change of its own (the tab is as dirty as the same
722                // open with current copies). Nothing is written to the PLM.
723                Ok(_) => {
724                    doc.absorb_open_write(&before, &document, key);
725                    // The refreshed copies re-heal on the run this edit causes.
726                    doc.arm_open_settle();
727                    if !quiet { doc.engine.push_notice(format!(
728                        "Updated {} from {} PLM revision{}: the copies saved in this assembly were older",
729                        replaced.join(", "),
730                        if replaced.len() == 1 { "its" } else { "their" },
731                        if replaced.len() == 1 { "" } else { "s" }
732                    )); }
733                }
734                Err(problem) => doc.engine.push_notice(format!("Could not update {} from the PLM: {problem}", replaced.join(", "))),
735            }
736            self.refreshed.insert(id, replaced);
737        }
738    }
739
740    /// Tab `id` was reloaded from its stored document: compare its copies
741    /// again, as on open (the earlier check's refresh went with the reload).
742    pub fn recheck(&mut self, id: u64) {
743        self.quiet.insert(id);
744        self.checked.remove(&id);
745        self.pending.remove(&id);
746        self.refreshed.remove(&id);
747        self.live.remove(&id);
748    }
749
750    /// The feed moved: every open document that embeds a revision it named
751    /// (every checked one when it could not say which) is due a live check.
752    pub fn moved(&mut self, docs: &crate::document::Documents, keys: &[String], stale: bool) {
753        let named: std::collections::BTreeSet<String> = keys.iter().filter_map(|k| revision_key_of(k)).collect();
754        for doc in docs.iter() {
755            if !self.checked.contains(&doc.id()) {
756                continue;
757            }
758            let Ok(document) = serde_json::from_str::<serde_json::Value>(&doc.engine.history_request_json()) else {
759                continue;
760            };
761            let embedded = crate::plm::cached_copies::revision_keys(&document);
762            let hit: Vec<String> = embedded.into_iter().filter(|k| named.contains(k)).collect();
763            if stale || !hit.is_empty() {
764                self.due.entry(doc.id()).or_default().extend(hit);
765            }
766        }
767    }
768
769    /// A live check answered: refresh what the user has not edited, in the
770    /// document and its baseline alike. The paths of edited copies whose
771    /// revisions moved, when there are any, for the pane to ask about.
772    fn follow(&mut self, doc: &mut crate::document::Document, sources: &HashMap<String, serde_json::Value>) -> Option<Vec<String>> {
773        use crate::plm::cached_copies::{moved_revisions, paths_of, refresh_except};
774        // A tab never saved (restored after a crash) has no baseline to tell
775        // the user's edits by: it is left as it is.
776        let mut baseline = doc.saved_document()?;
777        // The history's own document: `history_request_json` adds the session's
778        // workbench and thumbnail, which an edit written back would stamp into
779        // the history (and from there into the clean baseline).
780        let mut current: serde_json::Value = serde_json::from_str(&doc.engine.history.request_json()).ok()?;
781        let edited: std::collections::BTreeSet<String> = crate::document::owned_save::owned_changes(&baseline, &current)
782            .writes
783            .into_iter()
784            .filter_map(|w| revision_key_of(&w.key))
785            .collect();
786        // What changed on the PLM since the tab was last clean, by part (the
787        // washer, not the subs that hold it); the edited ones among them are asked about.
788        let moved = moved_revisions(&baseline, sources);
789        let changed = paths_of(&current, &moved);
790        let kept = self.kept.get(&doc.id());
791        let ask: std::collections::BTreeSet<String> = moved
792            .intersection(&edited)
793            .filter(|key| kept.and_then(|k| k.get(*key)) != sources.get(*key).map(|s| s.to_string()).as_ref())
794            .cloned()
795            .collect();
796        let asked = paths_of(&current, &ask);
797        let replaced = refresh_except(&mut current, sources, &edited);
798        if !replaced.is_empty() {
799            refresh_except(&mut baseline, sources, &edited);
800            let clean = !doc.is_dirty();
801            let key = doc.edit_key();
802            match doc.engine.edit_document_json(&current.to_string()) {
803                Ok(_) => {
804                    doc.rebase_saved_document(&baseline, key);
805                    if clean {
806                        // The refreshed copies re-heal on the run this edit
807                        // causes; in a clean tab that run is not the user's.
808                        doc.arm_open_settle();
809                    }
810                    self.updated += 1;
811                    let what = if changed.is_empty() { replaced.join(", ") } else { changed.join(", ") };
812                    doc.engine.push_notice(format!(
813                        "{what} changed on the PLM (saved by another client): updated {} in this assembly",
814                        replaced.join(", ")
815                    ));
816                }
817                Err(problem) => doc.engine.push_notice(format!("Could not update {} from the PLM: {problem}", replaced.join(", "))),
818            }
819        }
820        (!asked.is_empty()).then_some(asked)
821    }
822
823    /// The edited copies the pane asks about for document `id`.
824    pub fn asking(&self, id: u64) -> Option<&[String]> {
825        self.asking.get(&id).map(|(paths, _)| paths.as_slice())
826    }
827
828    /// The answer for document `id`: `true` takes their version of the edited
829    /// copies (the edits in them are discarded), `false` keeps the edits
830    /// (asked again only when the PLM moves again).
831    pub fn answer(&mut self, docs: &mut crate::document::Documents, id: u64, take_theirs: bool) {
832        let Some((paths, sources)) = self.asking.remove(&id) else { return };
833        if !take_theirs {
834            let kept = self.kept.entry(id).or_default();
835            for (key, source) in &sources {
836                kept.insert(key.clone(), source.to_string());
837            }
838            return;
839        }
840        let Some(doc) = docs.iter_mut().find(|d| d.id() == id) else { return };
841        let (Some(mut baseline), Ok(mut current)) =
842            (doc.saved_document(), serde_json::from_str::<serde_json::Value>(&doc.engine.history.request_json()))
843        else {
844            return;
845        };
846        let replaced = crate::plm::cached_copies::refresh(&mut current, &sources);
847        if replaced.is_empty() {
848            return;
849        }
850        crate::plm::cached_copies::refresh(&mut baseline, &sources);
851        let key = doc.edit_key();
852        match doc.engine.edit_document_json(&current.to_string()) {
853            Ok(_) => {
854                doc.rebase_saved_document(&baseline, key);
855                if !doc.is_dirty() {
856                    // The discarded edits were the only ones: the run this edit
857                    // causes re-heals the copies, which is not the user's change.
858                    doc.arm_open_settle();
859                }
860                // An edit whose mates moved other components: those poses
861                // re-solve back, but not to the same bits. Once they have, and
862                // nothing else differs, the tab is clean again.
863                doc.arm_converge();
864                self.updated += 1;
865                doc.engine.push_notice(format!(
866                    "{} now {} the other client's version; your edits there were discarded",
867                    paths.join(", "),
868                    if paths.len() == 1 { "shows" } else { "show" }
869                ));
870            }
871            Err(problem) => doc.engine.push_notice(format!("Could not update {} from the PLM: {problem}", replaced.join(", "))),
872        }
873    }
874
875    pub fn busy(&self) -> bool {
876        !self.pending.is_empty()
877    }
878
879    /// Live state for automation: refreshes so far, and the copies asked about.
880    pub fn live_json(&self, id: u64) -> serde_json::Value {
881        serde_json::json!({
882            "updated": self.updated,
883            "due": self.due.contains_key(&id) || self.live.contains_key(&id),
884            "asking": self.asking(id).unwrap_or(&[]),
885        })
886    }
887
888    /// The copies document `id` had refreshed when it opened.
889    pub fn refreshed(&self, id: u64) -> &[String] {
890        self.refreshed.get(&id).map_or(&[], Vec::as_slice)
891    }
892
893    /// How many revisions document `id`'s last check took from its copies
894    /// instead of reading them.
895    pub fn unread(&self, id: u64) -> usize {
896        self.unread.get(&id).copied().unwrap_or(0)
897    }
898}
899
900// --- copies pinned to a superseded revision -------------------------------------------
901
902/// One embedded revision that is no longer current, and the release that is.
903#[derive(Debug, Clone)]
904pub struct Superseded {
905    /// The pinned revision's store key, and the current release's.
906    pub key: String,
907    pub current_key: String,
908    pub number: String,
909    pub from: String,
910    pub lifecycle: String,
911    pub to: String,
912    /// The current release's document, for the update.
913    text: String,
914    /// Where it is placed, as library paths (`sub-b/plate`).
915    pub paths: Vec<String>,
916}
917
918/// The check on open for copies pinned to a superseded or obsolete revision
919/// (`plm::superseded`). Once per open, off its critical path: the pinned
920/// revisions' lifecycles come from the store index the session already holds
921/// (no request); only a part that IS superseded is read, once however often
922/// it is placed, for its current release and that release's document. One
923/// notice names what is stale. The pane offers Update only where the document
924/// may change (a draft no one else holds); a released assembly, or one held by
925/// someone else, pins its revisions by design and is only told.
926#[derive(Default)]
927pub struct SupersededCheck {
928    checked: std::collections::HashSet<u64>,
929    /// Frames spent waiting for the index to hold every pinned revision.
930    waits: HashMap<u64, u32>,
931    pending: HashMap<u64, Pending<Vec<Superseded>>>,
932    found: HashMap<u64, Vec<Superseded>>,
933    /// Documents whose Update question is still open.
934    asking: std::collections::HashSet<u64>,
935    updated: u64,
936    /// Revision reads made, for the cost report (state).
937    reads: u64,
938    /// Why a document keeps its revisions (released, held by someone), for the pane.
939    keeps: HashMap<u64, String>,
940    /// "Make a new revision using B", by the document it was asked from.
941    creating: HashMap<u64, Pending<(String, String)>>,
942    /// New revisions the user made to move on: updated as soon as their own
943    /// check finds the stale copies (the user already chose), as store keys.
944    auto: std::collections::HashSet<String>,
945    /// Made revisions to open, with the document they came from.
946    opened: Vec<(u64, String)>,
947    /// The revisions each checked document pins, with whether each read
948    /// superseded at its check: one that turns superseded (or obsolete) while
949    /// the document is open checks it again, so the user is told without
950    /// reopening it.
951    pinned: HashMap<u64, Vec<(String, bool)>>,
952}
953
954/// How many frames the check waits for the index to hold every revision a
955/// document names before checking with what it has (~10 s at 50 ms).
956const INDEX_WAIT_FRAMES: u32 = 200;
957
958impl SupersededCheck {
959    pub fn sync(&mut self, client: &Rc<PlmClient>, store: &dyn ModelStore, docs: &mut crate::document::Documents, waker: &Waker) {
960        use crate::plm::superseded::{current_release, is_superseded, pinned_keys};
961        let open: Vec<u64> = docs.iter().map(|d| d.id()).collect();
962        self.checked.retain(|id| open.contains(id));
963        self.waits.retain(|id, _| open.contains(id));
964        self.pending.retain(|id, _| open.contains(id));
965        self.found.retain(|id, _| open.contains(id));
966        self.asking.retain(|id| open.contains(id));
967        self.keeps.retain(|id, _| open.contains(id));
968        self.creating.retain(|id, _| open.contains(id));
969        self.pinned.retain(|id, _| open.contains(id));
970        // A newer revision released (or one retired) since the check: once
971        // the change feed has the pinned row's new lifecycle, check again.
972        let moved: Vec<u64> = self
973            .pinned
974            .iter()
975            .filter(|(id, keys)| {
976                !self.pending.contains_key(id)
977                    && keys.iter().any(|(key, was)| !was && store.plm_revision(key).is_some_and(|e| is_superseded(&e.lifecycle)))
978            })
979            .map(|(id, _)| *id)
980            .collect();
981        for id in moved {
982            self.pinned.remove(&id);
983            self.checked.remove(&id);
984            self.waits.remove(&id);
985        }
986        let made: Vec<(u64, Result<(String, String), String>)> =
987            self.creating.iter_mut().filter_map(|(id, pending)| pending.poll(waker).map(|answer| (*id, answer))).collect();
988        for (id, answer) in made {
989            self.creating.remove(&id);
990            let Some(doc) = docs.iter_mut().find(|d| d.id() == id) else { continue };
991            match answer {
992                Ok((key, text)) => {
993                    // The store mirror learns the new revision before it is opened
994                    // (as Save As → new revision does), or the open reads it absent.
995                    store.remember_saved_document(&store.canonical_identity(&key), &text);
996                    store.refresh_plm_index(std::slice::from_ref(&key), false);
997                    if let Some(key) = revision_key_of(&key) {
998                        self.auto.insert(key.clone());
999                        self.opened.push((id, key));
1000                    }
1001                    doc.engine.push_notice_as(
1002                        brep_render::engine_state::NoticeSeverity::Info,
1003                        "Made a new revision of this assembly, checked out to you: opening it with the current revisions",
1004                    );
1005                }
1006                Err(problem) => doc.engine.push_notice(format!("Could not make a new revision of this assembly: {problem}")),
1007            }
1008        }
1009        let mut apply_now = Vec::new();
1010        for doc in docs.iter() {
1011            let id = doc.id();
1012            if self.checked.contains(&id) || self.pending.contains_key(&id) || doc.name().and_then(revision_key_of).is_none() {
1013                continue;
1014            }
1015            let Ok(document) = serde_json::from_str::<serde_json::Value>(&doc.engine.history.request_json()) else { continue };
1016            let keys = pinned_keys(&document);
1017            let entries: Vec<(String, Option<crate::plm::client::IndexEntry>)> =
1018                keys.into_iter().map(|k| { let e = store.plm_revision(&k); (k, e) }).collect();
1019            if entries.iter().any(|(_, e)| e.is_none()) {
1020                let waited = self.waits.entry(id).or_default();
1021                *waited += 1;
1022                if *waited < INDEX_WAIT_FRAMES {
1023                    continue;
1024                }
1025            }
1026            self.checked.insert(id);
1027            self.pinned.insert(
1028                id,
1029                entries.iter().map(|(key, entry)| (key.clone(), entry.as_ref().is_some_and(|e| is_superseded(&e.lifecycle)))).collect(),
1030            );
1031            // Stale revisions, grouped by part: each part is read once.
1032            let mut by_part: std::collections::BTreeMap<String, Vec<(String, crate::plm::client::IndexEntry)>> = Default::default();
1033            for (key, entry) in entries {
1034                if let Some(entry) = entry.filter(|e| is_superseded(&e.lifecycle)) {
1035                    by_part.entry(entry.part_id.clone()).or_default().push((key, entry));
1036                }
1037            }
1038            if by_part.is_empty() {
1039                continue;
1040            }
1041            self.reads += 2 * by_part.len() as u64;
1042            let client = client.clone();
1043            self.pending.insert(
1044                id,
1045                Pending::new(Box::pin(async move {
1046                    let mut out = Vec::new();
1047                    for (part, stale) in by_part {
1048                        let path = format!("/api/parts/{}", crate::plm::identity::segment(&part));
1049                        let answer = client.call("GET", &path, None).await.map_err(|e| e.to_string())?;
1050                        let view: serde_json::Value = serde_json::from_slice(&answer.body).map_err(|e| e.to_string())?;
1051                        let Some((revision, label)) = current_release(&view) else {
1052                            // Nothing of it is released now: no update to
1053                            // offer, but the user is told (`current_key` empty).
1054                            for (key, entry) in stale {
1055                                out.push(Superseded {
1056                                    key,
1057                                    current_key: String::new(),
1058                                    number: entry.part_number.clone(),
1059                                    from: entry.revision_label.clone(),
1060                                    lifecycle: entry.lifecycle.clone(),
1061                                    to: String::new(),
1062                                    text: String::new(),
1063                                    paths: Vec::new(),
1064                                });
1065                            }
1066                            continue;
1067                        };
1068                        let current_key = crate::plm::identity::document_key(&part, &revision);
1069                        let Some(bytes) = client.get_document(&current_key).await.map_err(|e| e.to_string())? else { continue };
1070                        let text = String::from_utf8(bytes).map_err(|e| e.to_string())?;
1071                        for (key, entry) in stale {
1072                            if key == current_key {
1073                                continue;
1074                            }
1075                            out.push(Superseded {
1076                                key,
1077                                current_key: current_key.clone(),
1078                                number: entry.part_number.clone(),
1079                                from: entry.revision_label.clone(),
1080                                lifecycle: entry.lifecycle.clone(),
1081                                to: label.clone(),
1082                                text: text.clone(),
1083                                paths: Vec::new(),
1084                            });
1085                        }
1086                    }
1087                    Ok(out)
1088                })),
1089            );
1090        }
1091        let answered: Vec<(u64, Result<Vec<Superseded>, String>)> =
1092            self.pending.iter_mut().filter_map(|(id, pending)| pending.poll(waker).map(|answer| (*id, answer))).collect();
1093        for (id, answer) in answered {
1094            self.pending.remove(&id);
1095            let Some(doc) = docs.iter_mut().find(|d| d.id() == id) else { continue };
1096            let mut found = match answer {
1097                Ok(found) => found,
1098                Err(problem) => {
1099                    doc.engine.push_notice(format!("Could not check this assembly's PLM revisions: {problem}"));
1100                    continue;
1101                }
1102            };
1103            let Ok(document) = serde_json::from_str::<serde_json::Value>(&doc.engine.history.request_json()) else { continue };
1104            // Named by where they are PLACED: an entry no occurrence shows (a
1105            // Replace inside a sub-assembly leaves the old one behind) is not used.
1106            for item in &mut found {
1107                let mut names: Vec<String> = Vec::new();
1108                for placement in crate::plm::superseded::occurrences_of(&document, &item.key) {
1109                    let name = placement.name();
1110                    if !names.contains(&name) {
1111                        names.push(name);
1112                    }
1113                }
1114                item.paths = names;
1115            }
1116            found.retain(|item| !item.paths.is_empty());
1117            // A part with no released revision has nothing to update to.
1118            let (retired, rest): (Vec<Superseded>, Vec<Superseded>) = found.into_iter().partition(|item| item.current_key.is_empty());
1119            found = rest;
1120            for item in &retired {
1121                doc.engine.push_notice_as(
1122                    brep_render::engine_state::NoticeSeverity::Warning,
1123                    format!(
1124                        "{}: {} uses {} rev {}, and no revision of it is released now: replace it with another part, or have a revision of it released",
1125                        item.paths.join(", "),
1126                        item.number,
1127                        item.lifecycle,
1128                        item.from
1129                    ),
1130                );
1131            }
1132            if found.is_empty() {
1133                continue;
1134            }
1135            // Only a draft no one else holds may change; a released assembly
1136            // (or one in review, or held by someone else) keeps its revisions
1137            // by design and is told how to move on.
1138            let own_key = doc.name().and_then(revision_key_of);
1139            // A revision this check made (a draft, checked out to this user) may
1140            // not be in the index yet: it is editable, and the user chose it.
1141            let made_here = own_key.as_ref().is_some_and(|k| self.auto.contains(k));
1142            let own = own_key.as_ref().and_then(|key| store.plm_revision(key));
1143            let keeps = match &own {
1144                _ if made_here => None,
1145                Some(e) if e.lifecycle == "draft" && (e.locked_by.is_none() || e.locked_by_me) => None,
1146                Some(e) if e.lifecycle == "draft" => Some(format!("it is checked out by {}", e.locked_by.clone().unwrap_or_default())),
1147                Some(e) => Some(format!("it is {}", e.lifecycle)),
1148                None => Some("its PLM revision could not be read".to_string()),
1149            };
1150            for item in &found {
1151                let tail = keeps.as_ref().map_or(String::new(), |why| {
1152                    format!(". This assembly keeps rev {} ({why}); make a new revision of it to use rev {}", item.from, item.to)
1153                });
1154                doc.engine.push_notice_as(
1155                    brep_render::engine_state::NoticeSeverity::Warning,
1156                    format!("{}: {} uses {} rev {}; {} is current{tail}", item.paths.join(", "), item.number, item.lifecycle, item.from, item.to),
1157                );
1158            }
1159            match &keeps {
1160                // A revision the user made to move on: no second question.
1161                None if own_key.as_ref().is_some_and(|k| self.auto.remove(k)) => apply_now.push(id),
1162                None => {
1163                    self.asking.insert(id);
1164                }
1165                Some(why) => {
1166                    self.keeps.insert(id, why.clone());
1167                }
1168            }
1169            self.found.insert(id, found);
1170        }
1171        for id in apply_now {
1172            self.answer(docs, store, id, true);
1173        }
1174    }
1175
1176    /// Why document `id` keeps its stale revisions (it is released, or held by
1177    /// someone else), when it does.
1178    pub fn keeps(&self, id: u64) -> Option<&str> {
1179        self.keeps.get(&id).map(String::as_str).filter(|_| !self.found(id).is_empty())
1180    }
1181
1182    pub fn creating(&self, id: u64) -> bool {
1183        self.creating.contains_key(&id)
1184    }
1185
1186    /// The opt-in way on from a document that keeps its revisions: make its
1187    /// next revision (a draft, checked out to this user) holding the document
1188    /// as it is, open it, and update the stale copies there; the user saves it.
1189    pub fn make_new_revision(&mut self, client: &Rc<PlmClient>, docs: &crate::document::Documents, id: u64) {
1190        if self.creating.contains_key(&id) {
1191            return;
1192        }
1193        let Some(doc) = docs.iter().find(|d| d.id() == id) else { return };
1194        let Some((part, _)) = doc.name().and_then(crate::panels::plm_host::revision_of) else { return };
1195        let text = doc.engine.history_request_json();
1196        let client = client.clone();
1197        self.creating.insert(
1198            id,
1199            Pending::new(Box::pin(async move {
1200                let key = crate::panels::plm_parts::save_as_new_revision(&client, &part, "", &text).await?;
1201                Ok((key, text))
1202            })),
1203        );
1204    }
1205
1206    /// The revisions made since the last call, to open, with the document each came from.
1207    pub fn take_opened(&mut self) -> Vec<(u64, String)> {
1208        std::mem::take(&mut self.opened)
1209    }
1210
1211    /// The stale revisions found in document `id`.
1212    pub fn found(&self, id: u64) -> &[Superseded] {
1213        self.found.get(&id).map_or(&[], Vec::as_slice)
1214    }
1215
1216    pub fn asking(&self, id: u64) -> bool {
1217        self.asking.contains(&id) && !self.found(id).is_empty()
1218    }
1219
1220    /// The answer for document `id`: `true` replaces every stale placement with
1221    /// the current release (top-level ones through Replace, ones inside a
1222    /// sub-assembly through its definition, so mates are kept as Replace keeps
1223    /// them); `false` leaves it (the mark stays).
1224    pub fn answer(&mut self, docs: &mut crate::document::Documents, store: &dyn ModelStore, id: u64, update: bool) {
1225        use crate::panels::replace_component::{replace, replace_nested, ReplaceSource};
1226        self.asking.remove(&id);
1227        if !update {
1228            return;
1229        }
1230        let Some(found) = self.found.remove(&id) else { return };
1231        let Some(doc) = docs.iter_mut().find(|d| d.id() == id) else { return };
1232        for item in &found {
1233            store.remember_saved_document(&store.canonical_identity(&item.current_key), &item.text);
1234            let Ok(document) = serde_json::from_str::<serde_json::Value>(&doc.engine.history.request_json()) else { continue };
1235            let placements = crate::plm::superseded::occurrences_of(&document, &item.key);
1236            let source = ReplaceSource::Stored(item.current_key.clone());
1237            let top: Vec<String> = placements.iter().filter(|p| p.owners.is_empty()).map(|p| p.path.clone()).collect();
1238            let mut result = Ok(());
1239            if !top.is_empty() {
1240                result = replace(&mut doc.engine, store, &top, &source).map(|_| ());
1241            }
1242            // Inside sub-assemblies: each defining chain once (it edits the
1243            // definition, which every placement of that sub-assembly shows).
1244            let mut chains: Vec<&Vec<String>> = Vec::new();
1245            for placement in placements.iter().filter(|p| !p.owners.is_empty()) {
1246                if result.is_err() || chains.contains(&&placement.owners) {
1247                    continue;
1248                }
1249                chains.push(&placement.owners);
1250                result = replace_nested(&mut doc.engine, store, &placement.path, &source, true).map(|_| ());
1251            }
1252            match result {
1253                Ok(()) => {
1254                    self.updated += 1;
1255                    doc.engine.push_notice_as(
1256                        brep_render::engine_state::NoticeSeverity::Info,
1257                        format!("{}: {} updated from rev {} to rev {}", item.paths.join(", "), item.number, item.from, item.to),
1258                    );
1259                }
1260                Err(problem) => doc.engine.push_notice(format!("{}: could not update {} to rev {}: {problem}", item.paths.join(", "), item.number, item.to)),
1261            }
1262        }
1263    }
1264
1265    pub fn busy(&self) -> bool {
1266        !self.pending.is_empty() || !self.creating.is_empty()
1267    }
1268
1269    pub fn state_json(&self, id: u64) -> serde_json::Value {
1270        serde_json::json!({
1271            "checked": self.checked.contains(&id) && !self.pending.contains_key(&id),
1272            "superseded": self.found(id).iter().map(|s| serde_json::json!({
1273                "paths": s.paths, "number": s.number, "from": s.from, "lifecycle": s.lifecycle, "to": s.to,
1274            })).collect::<Vec<_>>(),
1275            "asking": self.asking(id),
1276            "keeps": self.keeps(id),
1277            "creating": self.creating(id),
1278            "updated": self.updated,
1279            "reads": self.reads,
1280        })
1281    }
1282}
1283