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