diff --git a/README.md b/README.md index d639f41..15c6bda 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Requires FCCS to be open with export destination already configured. Automates t - **Resumable** -- tracks completed drawers in `completed.txt`; safe to restart - **Screenshots** -- captures failure states for diagnosis - **Defensive** -- one bad drawer won't crash the entire run +- **Crash recovery** -- some documents (e.g. UltraTax "Diagnostics" files) crash FCCS's converter (`FileConversionEngine::convert() failed`), which aborts that drawer's export. The script detects the error dialog, screenshots and logs the crashing document, dismisses it, and records the drawer in `crashed.txt` so it's skipped on future runs instead of stalling. Handle crashed drawers manually (export them excluding the poison document); delete a line from `crashed.txt` to retry after fixing. ### Step 2b (optional): Verify Export Completeness @@ -99,6 +100,6 @@ Exported filenames follow the format `{drawer_id}_{client_name}_{folder_name}_{c All scripts read from `config.ini` (or specify `--config path\to\config.ini`). -- **`[paths]`** -- backup_dir, export_dir, output_dir, drawer_id_file, completed_file, ignore_file, log_file, screenshot_dir, manifest_dir, folder_list +- **`[paths]`** -- backup_dir, export_dir, output_dir, drawer_id_file, completed_file, ignore_file, crashed_file, log_file, screenshot_dir, manifest_dir, folder_list - **`[timeouts]`** -- nav_timeout, dialog_timeout, progress_appear, progress_finish, settle, confirm_timeout - **`[controls]`** -- FCCS window class names and button titles (rarely need changing) diff --git a/config.ini b/config.ini index 39bd760..dc6f282 100644 --- a/config.ini +++ b/config.ini @@ -6,6 +6,7 @@ output_dir = C:\Migration\Output drawer_id_file = C:\Migration\drawer_ids.txt completed_file = C:\Migration\completed.txt ignore_file = C:\Migration\ignore.txt +crashed_file = C:\Migration\crashed.txt log_file = C:\Migration\run_log.txt screenshot_dir = C:\Migration\screenshots manifest_dir = C:\Migration\manifests @@ -32,4 +33,6 @@ progress_title = Exporting Documents progress_class = #32770 saved_title = "Send to" file saved saved_class = #32770 -saved_ok_title = OK \ No newline at end of file +saved_ok_title = OK +error_title = FileCabinet CS +error_class = #32770 \ No newline at end of file diff --git a/fccs_export.py b/fccs_export.py index 6f0784c..5b4dde1 100755 --- a/fccs_export.py +++ b/fccs_export.py @@ -35,6 +35,16 @@ from pywinauto import timings from fccs_config import parse_args, load_config, make_logger, load_lines +# Per-drawer outcomes. +STATUS_SUCCESS = "success" # exported cleanly -> completed.txt +STATUS_CRASHED = "crashed" # FCCS converter crash -> crashed.txt (skip on rerun) +STATUS_FAILED = "failed" # navigation/timeout/other -> retried next run + +# Substrings that mark the FCCS crash error dialog body (case-insensitive), +# used to distinguish a converter crash from other "FileCabinet CS" prompts. +_ERROR_SIGNATURES = ("failed", "access violation", "convert") + + # --------------------------------------------------------------------------- # HELPERS # --------------------------------------------------------------------------- @@ -53,6 +63,12 @@ def mark_completed(path, drawer_id): f.write(drawer_id + "\n") +def mark_crashed(path, drawer_id): + """Append a drawer ID to the crashed log (skipped on future runs).""" + with open(path, "a", encoding="utf-8") as f: + f.write(drawer_id + "\n") + + def take_screenshot(window, drawer_id, tag, screenshot_dir, log): """Save a screenshot of a window for later diagnosis of a failure.""" from datetime import datetime @@ -256,6 +272,15 @@ def perform_export(dlg, drawer_id, ctrl, timeouts, log): def dismiss_any_dialog(app, ctrl, log): """Try to find and dismiss any stale dialogs left from a previous failure.""" + # Dismiss a lingering converter-crash error dialog (OK) + w = _find_error_dialog(ctrl) + if w: + try: + _click_dialog_button(w, "OK", log) + log(" Dismissed stale crash error dialog.") + except Exception: + pass + # Dismiss "Send to" file saved confirmation dialog (OK) w = _find_desktop_dialog(ctrl["saved_title"]) if w: @@ -294,12 +319,52 @@ def _click_dialog_button(dialog, button_title, log): dlg.wait_not("visible", timeout=10) -def wait_for_export(app, ctrl, timeouts, log): +def _find_error_dialog(ctrl): + """Find the FCCS converter-crash error dialog, if present. + + Matches a visible #32770 titled 'FileCabinet CS' whose body text carries a + crash signature (failed / access violation / convert), so we don't confuse + it with other 'FileCabinet CS' prompts. Returns the window or None. """ - Wait for the full export lifecycle to complete: - 1. 'Exporting Documents' progress dialog appears (export running) - 2. Poll until '"Send to" file saved' confirmation dialog appears - 3. Click OK to dismiss it + try: + windows = Desktop(backend="win32").windows() + except Exception: + return None + for w in windows: + try: + if w.class_name() != ctrl["error_class"] or not w.is_visible(): + continue + if ctrl["error_title"] not in w.window_text(): + continue + body = " ".join(s.window_text() + for s in w.children(class_name="Static")).lower() + if any(sig in body for sig in _ERROR_SIGNATURES): + return w + except Exception: + continue + return None + + +def _read_progress_doc(app, ctrl): + """Read the document name from the 'Exporting Documents' progress dialog.""" + try: + progress = app.window(title=ctrl["progress_title"], + class_name=ctrl["progress_class"]) + for st in progress.children(class_name="Static"): + t = st.window_text() + if t and "document" in t.lower(): + return t.strip() + except Exception: + pass + return "(unknown document)" + + +def wait_for_export(app, drawer_id, ctrl, timeouts, screenshot_dir, log): + """ + Wait for the export lifecycle to complete, watching for two outcomes: + - '"Send to" file saved' confirmation dialog -> STATUS_SUCCESS + - 'FileCabinet CS' converter-crash error -> STATUS_CRASHED + A drawer that shows neither before the deadline -> STATUS_FAILED. """ progress = app.window(title=ctrl["progress_title"], class_name=ctrl["progress_class"]) @@ -310,20 +375,35 @@ def wait_for_export(app, ctrl, timeouts, log): log(" NOTE: progress dialog not seen; checking for confirmation dialog " "(drawer may have exported very quickly).") - # Poll the entire desktop for the confirmation dialog. - # app.window() can miss it — the dialog may not be owned by the app. - # Use the exact saved_title to avoid matching the "Send To File Location" dialog. + # Poll the entire desktop each cycle for either the success dialog or a + # converter-crash error. app.window() can miss dialogs it doesn't own. deadline = time.time() + timeouts["progress_finish"] saved_win = None while time.time() < deadline: + # Read the current document BEFORE checking for the crash, so if the + # error dialog has already replaced the progress text we still logged it. + current_doc = _read_progress_doc(app, ctrl) + + err_win = _find_error_dialog(ctrl) + if err_win is not None: + log(f" CRASH: FCCS converter failed on {current_doc}. " + f"This aborts the drawer export.") + take_screenshot(err_win, drawer_id, "crash", screenshot_dir, log) + try: + _click_dialog_button(err_win, "OK", log) + log(" Crash dialog dismissed; FCCS returned to home screen.") + except Exception as e: + log(f" WARNING: could not dismiss crash dialog: {e}") + return STATUS_CRASHED + saved_win = _find_desktop_dialog(ctrl["saved_title"]) if saved_win is not None: break time.sleep(2) if saved_win is None: - log(f" ERROR: confirmation dialog never appeared within {timeouts['progress_finish']}s.") - return False + log(f" ERROR: no confirmation or crash within {timeouts['progress_finish']}s.") + return STATUS_FAILED log(f" Confirmation dialog found: {saved_win.window_text()!r}") @@ -335,12 +415,12 @@ def wait_for_export(app, ctrl, timeouts, log): log(" Export succeeded but dialog remains — will be dismissed next iteration.") log(" Export complete.") - return True + return STATUS_SUCCESS def export_drawer(app, main, search_box, drawer_id, ctrl, timeouts, screenshot_dir, manifest_dir, log): - """Full per-drawer sequence. Returns True on success.""" + """Full per-drawer sequence. Returns a STATUS_* outcome.""" log(f"Drawer {drawer_id}: starting") # 0. Dismiss any stale dialogs left from a previous failure @@ -349,7 +429,7 @@ def export_drawer(app, main, search_box, drawer_id, ctrl, timeouts, # 1. Navigate if not navigate_to_drawer(main, search_box, drawer_id, ctrl, timeouts, log): take_screenshot(main, drawer_id, "nav_fail", screenshot_dir, log) - return False + return STATUS_FAILED # 2. Open Send To File dialog try: @@ -357,7 +437,7 @@ def export_drawer(app, main, search_box, drawer_id, ctrl, timeouts, except Exception as e: log(f" ERROR opening Send To File dialog: {e}") take_screenshot(main, drawer_id, "dialog_fail", screenshot_dir, log) - return False + return STATUS_FAILED # 3 & 4. Select -> and OK (captures manifest before clicking OK) try: @@ -371,16 +451,18 @@ def export_drawer(app, main, search_box, drawer_id, ctrl, timeouts, dlg.child_window(title="Cancel", class_name="Button").click() except Exception: pass - return False + return STATUS_FAILED - # 5. Monitor progress - ok = wait_for_export(app, ctrl, timeouts, log) - if not ok: + # 5. Monitor progress (success / crash / timeout) + status = wait_for_export(app, drawer_id, ctrl, timeouts, screenshot_dir, log) + if status == STATUS_CRASHED: + return STATUS_CRASHED + if status != STATUS_SUCCESS: take_screenshot(main, drawer_id, "progress_fail", screenshot_dir, log) - return False + return STATUS_FAILED time.sleep(timeouts["settle"]) - return True + return STATUS_SUCCESS # --------------------------------------------------------------------------- @@ -397,6 +479,7 @@ def main(): "drawer_id_file": cfg.get("paths", "drawer_id_file"), "completed_file": cfg.get("paths", "completed_file"), "ignore_file": cfg.get("paths", "ignore_file"), + "crashed_file": cfg.get("paths", "crashed_file"), "screenshot_dir": cfg.get("paths", "screenshot_dir"), "manifest_dir": cfg.get("paths", "manifest_dir"), } @@ -422,6 +505,8 @@ def main(): "saved_title": cfg.get("controls", "saved_title"), "saved_class": cfg.get("controls", "saved_class"), "saved_ok_title": cfg.get("controls", "saved_ok_title"), + "error_title": cfg.get("controls", "error_title"), + "error_class": cfg.get("controls", "error_class"), } log("=" * 60) @@ -435,11 +520,16 @@ def main(): completed = load_completed(paths["completed_file"]) ignored = set(load_lines(paths["ignore_file"])) + crashed = set(load_lines(paths["crashed_file"])) - pending = [d for d in all_ids if d not in completed and d not in ignored] + pending = [d for d in all_ids + if d not in completed + and d not in ignored + and d not in crashed] log(f"Total drawers in list : {len(all_ids)}") log(f"Already completed : {len(completed)}") log(f"Ignored (skipped) : {len(ignored & set(all_ids))}") + log(f"Crashed (skipped) : {len(crashed & set(all_ids))}") log(f"Remaining to process : {len(pending)}") if not pending: @@ -456,14 +546,16 @@ def main(): success_count = 0 fail_count = 0 + crash_count = 0 failed_ids = [] + crashed_ids = [] for i, drawer_id in enumerate(pending, start=1): log(f"--- [{i}/{len(pending)}] Drawer {drawer_id} ---") try: - ok = export_drawer(app, main_win, search_box, drawer_id, ctrl, - timeouts, paths["screenshot_dir"], - paths["manifest_dir"], log) + status = export_drawer(app, main_win, search_box, drawer_id, ctrl, + timeouts, paths["screenshot_dir"], + paths["manifest_dir"], log) except Exception as e: log(f" UNEXPECTED ERROR on drawer {drawer_id}: {e}") try: @@ -471,13 +563,20 @@ def main(): paths["screenshot_dir"], log) except Exception: pass - ok = False + status = STATUS_FAILED - if ok: + if status == STATUS_SUCCESS: mark_completed(paths["completed_file"], drawer_id) success_count += 1 log(f" Drawer {drawer_id} DONE ({success_count} ok / " - f"{fail_count} failed so far)") + f"{fail_count} failed / {crash_count} crashed so far)") + elif status == STATUS_CRASHED: + mark_crashed(paths["crashed_file"], drawer_id) + crash_count += 1 + crashed_ids.append(drawer_id) + log(f" Drawer {drawer_id} CRASHED - added to crashed list; will be " + f"skipped on re-run. Handle manually (export excluding the " + f"poison document).") else: fail_count += 1 failed_ids.append(drawer_id) @@ -488,10 +587,15 @@ def main(): log("Run finished.") log(f" Succeeded : {success_count}") log(f" Failed : {fail_count}") + log(f" Crashed : {crash_count}") if failed_ids: log(f" Failed drawer IDs: {', '.join(failed_ids)}") log(" These were NOT marked complete; re-running the script " "will retry them.") + if crashed_ids: + log(f" Crashed drawer IDs: {', '.join(crashed_ids)}") + log(f" These were added to {paths['crashed_file']} and will be skipped " + "on re-run. Export them manually, excluding the crashing document.") log("=" * 60)