1use 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
34pub struct Planned {
36 pub folder: String,
37 pub documents: Vec<SourceDocument>,
38 pub plan: AdoptionPlan,
39 canonical: HashMap<String, String>,
42}
43
44struct Running {
46 answer: Pending<ImportReport>,
47 ledger: Rc<RefCell<Ledger>>,
48 stop: Rc<Cell<bool>>,
49 saved: Ledger,
50}
51
52pub 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 asked: Option<Ask>,
62 shown: Option<Ledger>,
65 local: bool,
67 pub hits: HashMap<String, egui::Rect>,
68}
69
70#[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 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 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 pub fn stop(&mut self) {
162 if let Some(running) = &self.running {
163 running.stop.set(true);
164 }
165 }
166
167 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 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 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 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 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#[derive(Clone, Debug, PartialEq)]
392pub enum RepointChoice {
393 Repoint(Vec<Repoint>),
396 KeepDocument,
399}
400
401pub 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
447pub fn revision_key_of(name: &str) -> Option<String> {
451 crate::plm::uses::revision_key_in(name)
452}
453
454#[derive(Default)]
459pub struct RepointCheck {
460 checked: std::collections::HashSet<u64>,
462 pending: HashMap<u64, Pending<Disagreement>>,
463 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 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 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 pub fn disagreement(&self, id: u64) -> Option<&Disagreement> {
523 self.found.get(&id)
524 }
525
526 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 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