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::automation::hit_keys::HitKeyDoc;
21use crate::panels::plm_parts::Pending;
22use crate::plm::adoption::{
23    plan_adoption, run_import, walk_tree, AdoptionPlan, ImportLedgers, ImportNumbering, ImportReport, Ledger,
24    SourceDocument,
25};
26use crate::plm::client::PlmClient;
27use crate::plm::uses::{Disagreement, Repoint};
28use crate::store::ModelStore;
29use eframe::egui;
30use std::cell::{Cell, RefCell};
31use std::collections::HashMap;
32use std::rc::Rc;
33use std::task::Waker;
34
35/// Hit keys this panel publishes, registered when the app draws it.
36pub const HIT_KEYS: &[HitKeyDoc] = &[
37    HitKeyDoc { panel: "plm_import", prefix: "plm_import:folder", meaning: "the folder to import", command: None },
38    HitKeyDoc { panel: "plm_import", prefix: "plm_import:plan", meaning: "read the folder and show what the import will do", command: None },
39    HitKeyDoc { panel: "plm_import", prefix: "plm_import:run", meaning: "run (or resume) the import", command: None },
40    HitKeyDoc { panel: "plm_import", prefix: "plm_import:stop", meaning: "stop after the step in flight", command: None },
41    HitKeyDoc { panel: "plm_import", prefix: "plm_import:repoint", meaning: "the re-point prompt's two answers", command: None },
42];
43
44/// What the folder holds and what the import will do with it.
45pub struct Planned {
46    pub folder: String,
47    pub documents: Vec<SourceDocument>,
48    pub plan: AdoptionPlan,
49    /// Every `sourceKey` in the folder's documents, spelled the explorer's
50    /// way, worked out once so the running import needs no store.
51    canonical: HashMap<String, String>,
52}
53
54/// A run in flight: its answer, the ledger as it moves, and Stop.
55struct Running {
56    answer: Pending<ImportReport>,
57    ledger: Rc<RefCell<Ledger>>,
58    stop: Rc<Cell<bool>>,
59    saved: Ledger,
60}
61
62/// The import panel.
63pub struct PlmImportPanel {
64    pub folder: String,
65    pub numbering: ImportNumbering,
66    pub planned: Option<Planned>,
67    pub report: Option<ImportReport>,
68    pub problem: Option<String>,
69    running: Option<Running>,
70    /// What `draw` was asked for, done by the next `sync`.
71    asked: Option<Ask>,
72    /// The ledger to show: the run's as it moves, else what the store holds
73    /// for this folder and server.
74    shown: Option<Ledger>,
75    /// Whether this session has local folders to read (see [`Self::sync`]).
76    local: bool,
77    pub hits: HashMap<String, egui::Rect>,
78}
79
80/// A click `draw` records for `sync`.
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82enum Ask {
83    Plan,
84    Start,
85    Stop,
86}
87
88impl Default for PlmImportPanel {
89    fn default() -> Self {
90        Self {
91            folder: String::new(),
92            numbering: ImportNumbering { part_type: "component".into(), number_from_name: false },
93            planned: None,
94            report: None,
95            problem: None,
96            running: None,
97            asked: None,
98            shown: None,
99            local: true,
100            hits: HashMap::new(),
101        }
102    }
103}
104
105impl PlmImportPanel {
106    pub fn new() -> Self {
107        Self::default()
108    }
109
110    pub fn running(&self) -> bool {
111        self.running.is_some()
112    }
113
114    /// Read the folder and plan. Reads only.
115    pub fn plan(&mut self, store: &dyn ModelStore) {
116        self.report = None;
117        self.problem = None;
118        match walk_tree(store, &self.folder) {
119            Ok(documents) => {
120                let mut canonical = HashMap::new();
121                for document in &documents {
122                    let Ok(value) = serde_json::from_str::<serde_json::Value>(&document.contents) else {
123                        continue;
124                    };
125                    collect_keys(&value, &mut |key| {
126                        canonical.entry(key.to_string()).or_insert_with(|| store.canonical_identity(key));
127                    });
128                }
129                let lookup = |key: &str| canonical.get(key).cloned().unwrap_or_else(|| key.to_string());
130                let plan = plan_adoption(&documents, &lookup);
131                self.planned = Some(Planned { folder: self.folder.clone(), documents, plan, canonical });
132            }
133            Err(problem) => {
134                self.planned = None;
135                self.problem = Some(problem);
136            }
137        }
138    }
139
140    /// Start (or resume) the import of the planned folder into `server`.
141    pub fn start(&mut self, store: &dyn ModelStore, client: Rc<PlmClient>, server: &str) {
142        let Some(planned) = &self.planned else {
143            return;
144        };
145        let ledger = ImportLedgers::load(store).ledger(server, &planned.folder);
146        let shared = Rc::new(RefCell::new(ledger.clone()));
147        let stop = Rc::new(Cell::new(false));
148        let (plan, documents, canonical) = (planned.plan.clone(), planned.documents.clone(), planned.canonical.clone());
149        let numbering = self.numbering.clone();
150        let (progress, stopping) = (shared.clone(), stop.clone());
151        let future = async move {
152            let lookup = |key: &str| canonical.get(key).cloned().unwrap_or_else(|| key.to_string());
153            let mut ledger = ledger;
154            let report = run_import(&client, &plan, &documents, &mut ledger, &lookup, &numbering, &mut |moved| {
155                *progress.borrow_mut() = moved.clone();
156                !stopping.get()
157            })
158            .await;
159            Ok(report)
160        };
161        self.report = None;
162        self.running = Some(Running {
163            answer: Pending::new(Box::pin(future)),
164            ledger: shared,
165            stop,
166            saved: Ledger::default(),
167        });
168    }
169
170    /// Ask the run to stop after the step in flight.
171    pub fn stop(&mut self) {
172        if let Some(running) = &self.running {
173            running.stop.set(true);
174        }
175    }
176
177    /// Fold in the run's progress: save a ledger that moved, take the report
178    /// when it ends.
179    pub fn poll(&mut self, store: &dyn ModelStore, server: &str, waker: &Waker) {
180        let Some(running) = &mut self.running else {
181            return;
182        };
183        let answer = running.answer.poll(waker);
184        let now = running.ledger.borrow().clone();
185        if now != running.saved {
186            if let Some(planned) = &self.planned {
187                let mut ledgers = ImportLedgers::load(store);
188                if let Err(problem) = ledgers.save(store, server, &planned.folder, &now) {
189                    self.problem = Some(format!("the import's progress could not be saved: {problem}"));
190                }
191            }
192            running.saved = now;
193        }
194        if let Some(answer) = answer {
195            self.running = None;
196            match answer {
197                Ok(report) => self.report = Some(report),
198                Err(problem) => self.problem = Some(problem),
199            }
200        }
201    }
202
203    /// The store's half of a frame, for a host whose drawing has no store:
204    /// seed the folder from the explorer, act on what `draw` was asked last
205    /// frame, fold in the run's progress (saving the ledger), and refresh the
206    /// shown ledger.
207    ///
208    /// `files` is the store the folder is READ from — this machine's own file
209    /// store, never the PLM's (`ModelStore::local_files` in a PLM session);
210    /// `store` holds the ledger. `None`: this session has no local folders
211    /// (the browser), and the panel says so.
212    pub fn sync(&mut self, store: &dyn ModelStore, files: Option<&dyn ModelStore>, client: Option<Rc<PlmClient>>, server: &str, waker: &Waker) {
213        self.local = files.is_some();
214        if self.folder.is_empty() {
215            if let Some(files) = files {
216                self.folder = files.browser_location();
217            }
218        }
219        match self.asked.take() {
220            Some(Ask::Plan) => match files {
221                Some(files) => self.plan(files),
222                None => self.problem = Some("importing a folder needs the desktop app: this session has no local folders".into()),
223            },
224            Some(Ask::Start) => {
225                if let Some(client) = client {
226                    self.start(store, client, server);
227                }
228            }
229            Some(Ask::Stop) => self.stop(),
230            None => {}
231        }
232        self.poll(store, server, waker);
233        self.shown = match (&self.running, &self.planned) {
234            (Some(running), _) => Some(running.ledger.borrow().clone()),
235            (None, Some(planned)) => Some(ImportLedgers::load(store).ledger(server, &planned.folder)),
236            (None, None) => None,
237        };
238    }
239
240    /// `{folder, documents, parts, assemblies, refused, external, running,
241    /// report, problem}` — for scripts, through the PLM pane's state.
242    pub fn state_json(&self) -> serde_json::Value {
243        let plan = self.planned.as_ref().map(|p| &p.plan);
244        serde_json::json!({
245            "folder": self.folder,
246            "local": self.local,
247            "documents": self.planned.as_ref().map_or(0, |p| p.documents.len()),
248            "parts": plan.map_or(0, |p| p.parts.len()),
249            "assemblies": plan.map_or(0, |p| p.parts.iter().filter(|x| x.assembly).count()),
250            "refused": plan.map_or(0, |p| p.refused.len()),
251            "external": plan.map_or(0, |p| p.external.len()),
252            "running": self.running(),
253            "report": self.report.as_ref().map(|r| serde_json::json!({
254                "minted": r.minted, "uploaded": r.uploaded, "published": r.published,
255                "refused": r.refused.iter().map(|(d, why)| format!("{d}: {why}")).collect::<Vec<_>>(),
256            })),
257            "problem": self.problem,
258        })
259    }
260
261    /// Draw the panel from what `sync` left, recording what the user asks
262    /// for; the next `sync` does it. `can_import` is whether a PLM is signed
263    /// in — without one the panel says so and imports nothing.
264    pub fn draw(&mut self, ui: &mut egui::Ui, can_import: bool) {
265        self.hits.clear();
266        if !self.local {
267            ui.weak("Importing a folder of documents runs in the desktop app: this session has no local folders.");
268            return;
269        }
270        ui.horizontal(|ui| {
271            ui.label("Folder");
272            let field = ui.add_enabled(!self.running(), egui::TextEdit::singleline(&mut self.folder));
273            self.hits.insert("plm_import:folder".into(), field.rect);
274            let plan = ui.add_enabled(!self.running(), egui::Button::new("Read folder"));
275            self.hits.insert("plm_import:plan".into(), plan.rect);
276            if plan.clicked() {
277                self.asked = Some(Ask::Plan);
278            }
279        });
280        ui.horizontal(|ui| {
281            ui.label("Part type");
282            ui.add_enabled(!self.running(), egui::TextEdit::singleline(&mut self.numbering.part_type).desired_width(120.0));
283            ui.add_enabled(
284                !self.running(),
285                egui::Checkbox::new(&mut self.numbering.number_from_name, "number each part by its file name"),
286            );
287        });
288        if let Some(planned) = &self.planned {
289            let plan = &planned.plan;
290            let assemblies = plan.parts.iter().filter(|p| p.assembly).count();
291            ui.label(format!(
292                "{} document{} → {} part{}, {} with a uses list",
293                planned.documents.len(),
294                plural(planned.documents.len()),
295                plan.parts.len(),
296                plural(plan.parts.len()),
297                assemblies
298            ));
299            for (document, reason) in &plan.refused {
300                ui.colored_label(ui.visuals().warn_fg_color, format!("not imported: {document} — {reason}"));
301            }
302            for (document, key) in &plan.external {
303                ui.colored_label(
304                    ui.visuals().warn_fg_color,
305                    format!("{document} uses '{key}', which is not in this folder: its uses list will be refused"),
306                );
307            }
308            if let Some(ledger) = &self.shown {
309                let minted = ledger.entries.len();
310                let uploaded = ledger.entries.values().filter(|e| e.uploaded).count();
311                let published = ledger.entries.values().filter(|e| e.published).count();
312                ui.weak(format!(
313                    "on this server: {minted}/{} minted, {uploaded}/{} uploaded, {published}/{assemblies} uses lists",
314                    plan.parts.len(),
315                    plan.parts.len()
316                ));
317            }
318        }
319        ui.horizontal(|ui| match (can_import, self.running()) {
320            (_, true) => {
321                ui.spinner();
322                let stop = ui.button("Stop");
323                self.hits.insert("plm_import:stop".into(), stop.rect);
324                if stop.clicked() {
325                    self.asked = Some(Ask::Stop);
326                }
327            }
328            (true, false) => {
329                let run = ui.add_enabled(self.planned.is_some(), egui::Button::new("Import"));
330                self.hits.insert("plm_import:run".into(), run.rect);
331                if run.clicked() {
332                    self.asked = Some(Ask::Start);
333                }
334            }
335            (false, false) => {
336                ui.weak("Sign in to a PLM to import.");
337            }
338        });
339        if let Some(report) = &self.report {
340            ui.label(format!(
341                "minted {}, uploaded {}, published {}",
342                report.minted, report.uploaded, report.published
343            ));
344            for (document, sentence) in &report.refused {
345                ui.colored_label(ui.visuals().error_fg_color, format!("{document}: {sentence}"));
346            }
347        }
348        if let Some(problem) = &self.problem {
349            ui.colored_label(ui.visuals().error_fg_color, problem);
350        }
351    }
352
353    /// Both halves in one call, for a caller that has the store while it
354    /// draws. The second `sync` acts on this frame's click at once, and polls
355    /// a run started THIS frame — only a poll registers its waker.
356    pub fn show(&mut self, ui: &mut egui::Ui, store: &dyn ModelStore, client: Option<Rc<PlmClient>>, server: &str) {
357        let waker = repaint_waker(ui.ctx());
358        self.sync(store, Some(store), client.clone(), server, &waker);
359        self.draw(ui, client.is_some());
360        self.sync(store, Some(store), client, server, &waker);
361    }
362}
363
364fn collect_keys(document: &serde_json::Value, found: &mut dyn FnMut(&str)) {
365    let Some(library) = document.get("partsLibrary").and_then(serde_json::Value::as_object) else {
366        return;
367    };
368    for entry in library.values() {
369        if let Some(key) = entry.get("sourceKey").and_then(serde_json::Value::as_str) {
370            if !key.is_empty() {
371                found(key);
372            }
373        }
374        if let Some(nested) = entry.get("document") {
375            collect_keys(nested, found);
376        }
377    }
378}
379
380fn plural(count: usize) -> &'static str {
381    if count == 1 {
382        ""
383    } else {
384        "s"
385    }
386}
387
388fn repaint_waker(ctx: &egui::Context) -> Waker {
389    struct Repaint(egui::Context);
390    impl std::task::Wake for Repaint {
391        fn wake(self: std::sync::Arc<Self>) {
392            self.0.request_repaint();
393        }
394    }
395    Waker::from(std::sync::Arc::new(Repaint(ctx.clone())))
396}
397
398// --- the re-point prompt --------------------------------------------------------------
399
400/// The answer to the re-point prompt.
401#[derive(Clone, Debug, PartialEq)]
402pub enum RepointChoice {
403    /// Move these occurrences (`plm::uses::repoint` each, then Update
404    /// Components brings the new parts in).
405    Repoint(Vec<Repoint>),
406    /// Keep the document as it is. Its next save republishes the list from
407    /// the document, which undoes the swap — the prompt says so.
408    KeepDocument,
409}
410
411/// The prompt shown on opening an assembly whose uses list and document
412/// disagree. `names` turns a part id into what a person reads (its number).
413pub struct RepointPrompt;
414
415impl RepointPrompt {
416    pub fn show(
417        ui: &mut egui::Ui,
418        found: &Disagreement,
419        names: &dyn Fn(&str) -> String,
420        hits: &mut HashMap<String, egui::Rect>,
421    ) -> Option<RepointChoice> {
422        ui.label("This assembly's parts list on the PLM no longer matches the document:");
423        for repoint in &found.repoints {
424            ui.label(format!(
425                "•  {} ({}) → {}",
426                names(&repoint.from.0),
427                repoint.occurrences.join(", "),
428                names(&repoint.to.0)
429            ));
430        }
431        for placed in &found.unlisted {
432            ui.weak(format!("•  placed here but not on the list: {} ×{}", names(&placed.line.part), placed.line.quantity));
433        }
434        for listed in &found.unplaced {
435            ui.weak(format!("•  on the list but not placed: {} ×{}", names(&listed.part), listed.quantity));
436        }
437        let mut choice = None;
438        ui.horizontal(|ui| {
439            if !found.repoints.is_empty() {
440                let button = ui.button("Re-point to match the PLM");
441                hits.insert("plm_import:repoint:apply".into(), button.rect);
442                if button.clicked() {
443                    choice = Some(RepointChoice::Repoint(found.repoints.clone()));
444                }
445            }
446            let keep = ui.button("Keep the document");
447            hits.insert("plm_import:repoint:keep".into(), keep.rect);
448            if keep.clicked() {
449                choice = Some(RepointChoice::KeepDocument);
450            }
451        });
452        ui.weak("Keeping it means the next save publishes the parts list from the document again.");
453        choice
454    }
455}
456
457// --- the check on open ------------------------------------------------------------------
458
459/// The store key a document name carries on a PLM store.
460pub fn revision_key_of(name: &str) -> Option<String> {
461    crate::plm::uses::revision_key_in(name)
462}
463
464/// S5's check on open: every PLM assembly document is compared once with
465/// its revision's uses list, and a disagreement (Replace everywhere swapped a
466/// child in the LIST) is offered to the user as a re-point before their next
467/// save would republish the list from the document and undo the swap.
468#[derive(Default)]
469pub struct RepointCheck {
470    /// Documents already checked (or being checked), by document id.
471    checked: std::collections::HashSet<u64>,
472    pending: HashMap<u64, Pending<Disagreement>>,
473    /// What each document disagrees about, until the user answers.
474    found: HashMap<u64, Disagreement>,
475    pub hits: HashMap<String, egui::Rect>,
476}
477
478impl RepointCheck {
479    pub fn new() -> Self {
480        Self::default()
481    }
482
483    /// Once a frame: start a check for each newly opened PLM assembly, and
484    /// fold in the answers. `waker` repaints.
485    pub fn sync(&mut self, client: &Rc<PlmClient>, docs: &crate::document::Documents, waker: &Waker) {
486        let open: Vec<u64> = docs.iter().map(|d| d.id()).collect();
487        self.checked.retain(|id| open.contains(id));
488        self.pending.retain(|id, _| open.contains(id));
489        self.found.retain(|id, _| open.contains(id));
490        for doc in docs.iter() {
491            if self.checked.contains(&doc.id()) {
492                continue;
493            }
494            let Some(key) = doc.name().and_then(revision_key_of) else {
495                continue;
496            };
497            self.checked.insert(doc.id());
498            let Ok(document) = serde_json::from_str::<serde_json::Value>(&doc.engine.history_request_json()) else {
499                continue;
500            };
501            if !crate::plm::uses::publishes_on_save(&document) {
502                continue;
503            }
504            let client = client.clone();
505            self.pending.insert(
506                doc.id(),
507                Pending::new(Box::pin(async move { crate::plm::uses::check_on_open(&client, &key, &document).await })),
508            );
509        }
510        let answered: Vec<(u64, Result<Disagreement, String>)> = self
511            .pending
512            .iter_mut()
513            .filter_map(|(id, pending)| pending.poll(waker).map(|answer| (*id, answer)))
514            .collect();
515        for (id, answer) in answered {
516            self.pending.remove(&id);
517            // A failed check is not a disagreement: nothing is offered, and
518            // the save republishes as it always does.
519            if let Ok(found) = answer {
520                if !found.is_empty() {
521                    self.found.insert(id, found);
522                }
523            }
524        }
525    }
526
527    pub fn busy(&self) -> bool {
528        !self.pending.is_empty()
529    }
530
531    /// What document `id` disagrees about, if anything is still unanswered.
532    pub fn disagreement(&self, id: u64) -> Option<&Disagreement> {
533        self.found.get(&id)
534    }
535
536    /// Apply the user's answer to document `id`. A re-point edits the
537    /// document (one undoable edit, dirty until saved; the save republishes
538    /// the list, now matching the server's) — Update Components then brings
539    /// the new parts in.
540    pub fn answer(&mut self, docs: &mut crate::document::Documents, id: u64, choice: RepointChoice) -> Result<(), String> {
541        self.found.remove(&id);
542        let RepointChoice::Repoint(repoints) = choice else {
543            return Ok(());
544        };
545        let Some(doc) = docs.iter_mut().find(|d| d.id() == id) else {
546            return Ok(());
547        };
548        let mut document: serde_json::Value =
549            serde_json::from_str(&doc.engine.history_request_json()).map_err(|e| e.to_string())?;
550        for repoint in &repoints {
551            crate::plm::uses::repoint(&mut document, repoint, &crate::plm::uses::plm_identity);
552        }
553        doc.engine.edit_document_json(&document.to_string()).map(|_| ())
554    }
555
556    /// Draw the prompt for the active document, if it has one; apply the
557    /// answer the frame it is given. Part ids are shown as given (the host
558    /// may pass a namer that turns them into numbers).
559    pub fn ui(&mut self, ui: &mut egui::Ui, docs: &mut crate::document::Documents, names: &dyn Fn(&str) -> String) -> Result<(), String> {
560        self.hits.clear();
561        let id = docs.active().id();
562        let Some(found) = self.found.get(&id).cloned() else {
563            return Ok(());
564        };
565        match RepointPrompt::show(ui, &found, names, &mut self.hits) {
566            Some(choice) => self.answer(docs, id, choice),
567            None => Ok(()),
568        }
569    }
570}
571