1use 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
35pub 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
44pub struct Planned {
46 pub folder: String,
47 pub documents: Vec<SourceDocument>,
48 pub plan: AdoptionPlan,
49 canonical: HashMap<String, String>,
52}
53
54struct Running {
56 answer: Pending<ImportReport>,
57 ledger: Rc<RefCell<Ledger>>,
58 stop: Rc<Cell<bool>>,
59 saved: Ledger,
60}
61
62pub 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 asked: Option<Ask>,
72 shown: Option<Ledger>,
75 local: bool,
77 pub hits: HashMap<String, egui::Rect>,
78}
79
80#[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 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 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 pub fn stop(&mut self) {
172 if let Some(running) = &self.running {
173 running.stop.set(true);
174 }
175 }
176
177 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 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 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 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 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#[derive(Clone, Debug, PartialEq)]
402pub enum RepointChoice {
403 Repoint(Vec<Repoint>),
406 KeepDocument,
409}
410
411pub 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
457pub fn revision_key_of(name: &str) -> Option<String> {
461 crate::plm::uses::revision_key_in(name)
462}
463
464#[derive(Default)]
469pub struct RepointCheck {
470 checked: std::collections::HashSet<u64>,
472 pending: HashMap<u64, Pending<Disagreement>>,
473 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 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 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 pub fn disagreement(&self, id: u64) -> Option<&Disagreement> {
533 self.found.get(&id)
534 }
535
536 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 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