""" FileCabinet CS - Batch Export Automation (Step 2) ================================================= Reads a list of drawer IDs from a file, then for each drawer: 1. Navigates to it in the main window (type ID -> Go) 2. Opens File > Send To > File 3. Clicks "Select ->" (grabs all documents via the tree root) 4. Clicks OK 5. Waits for the "Exporting Documents" progress dialog to appear and finish 6. Logs success/failure and moves to the next drawer Key safety features: - RESUME: completed drawer IDs are appended to a progress log. On restart, already-completed drawers are skipped. A multi-hour run that dies partway can simply be re-run and it picks up where it left off. - LOGGING: every action and error is timestamped to a log file. - DEFENSIVE: unexpected states are caught, logged, screenshotted, and the run continues with the next drawer rather than crashing. PREREQUISITES: - Run from 32-bit Python (matches FileCabinet CS's architecture) - FileCabinet CS already open, pointed at the correct data location - Export options/checkboxes and destination already set MANUALLY once (they persist between exports within a session) - Neither FileCabinet nor this script running elevated (they must match) """ import time import sys import os from pywinauto import Desktop, Application 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 # --------------------------------------------------------------------------- def load_completed(path): """Read the set of already-completed drawer IDs for resume support.""" if not os.path.exists(path): return set() with open(path, "r", encoding="utf-8") as f: return {line.strip() for line in f if line.strip()} def mark_completed(path, drawer_id): """Append a drawer ID to the completed log (resume tracking).""" with open(path, "a", encoding="utf-8") as f: 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 try: os.makedirs(screenshot_dir, exist_ok=True) fname = os.path.join( screenshot_dir, f"{drawer_id}_{tag}_{datetime.now():%Y%m%d_%H%M%S}.png" ) window.capture_as_image().save(fname) log(f" Screenshot saved: {fname}") except Exception as e: log(f" Could not capture screenshot: {e}") # --------------------------------------------------------------------------- # CONNECTION # --------------------------------------------------------------------------- def connect_main(ctrl, log): """Find and connect to the running FileCabinet CS main window.""" target = None for w in Desktop(backend="win32").windows(): if w.class_name() == ctrl["main_class"]: target = w break if target is None: log("ERROR: FileCabinet CS window not found. Is it open?") sys.exit(1) app = Application(backend="win32").connect(handle=target.handle) main = app.window(handle=target.handle) log(f"Connected to: {main.window_text()}") return app, main # --------------------------------------------------------------------------- # PER-DRAWER EXPORT # --------------------------------------------------------------------------- def find_search_box(main, ctrl, timeouts, log): """Locate the drawer search box reliably using the Go button as an anchor. The main window has several Edit controls (notably the data-location box inside a ComboBox). Their best_match names ("Edit", "Edit0"...) are unstable and the placeholder title only exists in a fresh session, so we anchor off the Go button instead: the search box sits on the same row (same top Y) as Go, immediately to its left. Call once before the drawer loop — the returned wrapper stays valid for the window's lifetime. """ go = main.child_window(title=ctrl["go_button_title"], class_name="Button") go.wait("visible ready", timeout=timeouts["nav"]) go_rect = go.rectangle() edits = main.children(class_name="Edit") if not edits: edits = main.descendants(class_name="Edit") tolerance = 10 candidates = [ e for e in edits if abs(e.rectangle().top - go_rect.top) <= tolerance ] if not candidates: raise RuntimeError( "Could not find the drawer search box (no Edit control on the " "Go button's row). FCCS may not be on the main drawer view." ) # If several share the row, pick the one whose right edge is closest to # (and left of) the Go button — that's the box directly beside Go. left_of_go = [e for e in candidates if e.rectangle().right <= go_rect.left] pool = left_of_go if left_of_go else candidates box = max(pool, key=lambda e: e.rectangle().right) r = box.rectangle() log(f" Search box located at {(r.left, r.top, r.right, r.bottom)} " f"(Go button top={go_rect.top})") return box def navigate_to_drawer(main, search_box, drawer_id, ctrl, timeouts, log): """Type the drawer ID into the search box and click Go.""" # search_box is a control wrapper (from find_search_box), not a # WindowSpecification, so it has no .wait() — it was already confirmed # present/visible when located. set_edit_text works directly on it. search_box.set_edit_text("") search_box.set_edit_text(drawer_id) time.sleep(timeouts["settle"]) main.child_window(title=ctrl["go_button_title"], class_name="Button").click() deadline = time.time() + timeouts["nav"] # Whatever drawer your are actively on becomes the actual name of the App Window. We are checking that again to make sure we found the drawer. while time.time() < deadline: title = main.window_text() if f"[{drawer_id}" in title: log(f" Navigated to drawer {drawer_id}: {title}") return True time.sleep(0.3) log(f" WARNING: window title did not confirm drawer {drawer_id} " f"(title is: {main.window_text()!r})") return False def open_send_to_file(main, app, ctrl, timeouts): """Open File > Send To > File and return the dialog window object.""" main.menu_select("File->Send To->File") dlg = app.window(title=ctrl["dialog_title"], class_name=ctrl["dialog_class"]) dlg.wait("visible ready", timeout=timeouts["dialog"]) return dlg def read_manifest(dlg, drawer_id, log): """Read the selected documents from the ListView into clean rows. The ListView (`List1`) has columns Drawer ID, Page Title (the document name) and Application. `texts()` returns a flat, row-major dump led by the control's own name ('List1'), which is why the raw capture looked messy. This parses it into one row per document and drops the redundant Drawer ID column (always equal to drawer_id) and any empty columns, leaving the document name (and Application) per line. Returns a list of rows (each a list of cell strings), [] if empty, or None if it couldn't be read. """ try: lv = dlg.child_window(title="List1", class_name="SysListView32") n_rows = lv.item_count() except Exception as e: log(f" WARNING: could not read manifest list: {e}") return None if n_rows == 0: log(" WARNING: ListView is empty after Select") return [] try: n_cols = lv.column_count() except Exception: n_cols = 0 if not n_cols or n_cols < 1: n_cols = 3 # observed columns: Drawer ID, Page Title, Application # Read every cell. Primary: get_item(row, col). Fallback: reshape texts(). try: grid = [[lv.get_item(r, c).text() for c in range(n_cols)] for r in range(n_rows)] except Exception as e: log(f" NOTE: get_item failed ({e}); falling back to texts().") raw = lv.texts() if raw and raw[0] == lv.window_text(): raw = raw[1:] # drop the control's own name ('List1') grid = [raw[i:i + n_cols] for i in range(0, len(raw), n_cols)] grid = [row for row in grid if len(row) == n_cols] # Keep only columns that carry real, non-redundant info. keep_cols = [] for c in range(n_cols): vals = [(row[c].strip() if c < len(row) else "") for row in grid] if all(v == drawer_id for v in vals): continue # redundant Drawer ID column if all(v == "" for v in vals): continue # empty column keep_cols.append(c) rows = [[(row[c] if c < len(row) else "") for c in keep_cols] for row in grid] log(f" Manifest captured: {len(rows)} documents selected") return rows def save_manifest(drawer_id, items, manifest_dir, log): """Save the list of expected export items to a manifest file.""" os.makedirs(manifest_dir, exist_ok=True) path = os.path.join(manifest_dir, f"{drawer_id}.txt") with open(path, "w", encoding="utf-8") as f: for row in items: if isinstance(row, (list, tuple)): f.write("\t".join(str(c) for c in row) + "\n") else: f.write(str(row) + "\n") log(f" Manifest saved: {path}") def perform_export(dlg, drawer_id, ctrl, timeouts, log): """Click Select -> then OK to start the export. Returns manifest rows.""" select_btn = dlg.child_window(title=ctrl["select_btn_title"], class_name="Button") select_btn.wait("visible ready enabled", timeout=timeouts["dialog"]) select_btn.click() ok_btn = dlg.child_window(title=ctrl["ok_btn_title"], class_name="Button") ok_btn.wait("visible ready enabled", timeout=timeouts["select"]) manifest = read_manifest(dlg, drawer_id, log) ok_btn.click() return manifest 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: try: _click_dialog_button(w, "OK", log) log(" Dismissed stale confirmation dialog.") except Exception: pass # Dismiss Send To File Location dialog (Cancel) w = _find_desktop_dialog(ctrl["dialog_title"]) if w: try: _click_dialog_button(w, "Cancel", log) log(" Dismissed stale Send To File dialog.") except Exception: pass def _find_desktop_dialog(title_match): """Search all top-level windows for a visible #32770 dialog matching title.""" for w in Desktop(backend="win32").windows(): try: if w.class_name() == "#32770" and w.is_visible() and title_match in w.window_text(): return w except Exception: continue return None def _click_dialog_button(dialog, button_title, log): """Connect to a desktop dialog and click a button on it.""" app = Application(backend="win32").connect(handle=dialog.handle) dlg = app.window(handle=dialog.handle) dlg.child_window(title=button_title, class_name="Button").click() dlg.wait_not("visible", timeout=10) 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. """ 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"]) try: progress.wait("visible", timeout=timeouts["progress_appear"]) log(" Export in progress...") except timings.TimeoutError: log(" NOTE: progress dialog not seen; checking for confirmation dialog " "(drawer may have exported very quickly).") # 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: no confirmation or crash within {timeouts['progress_finish']}s.") return STATUS_FAILED log(f" Confirmation dialog found: {saved_win.window_text()!r}") try: _click_dialog_button(saved_win, "OK", log) log(" Confirmation dismissed.") except Exception as e: log(f" WARNING: could not dismiss confirmation dialog: {e}") log(" Export succeeded but dialog remains — will be dismissed next iteration.") log(" Export complete.") return STATUS_SUCCESS def export_drawer(app, main, search_box, drawer_id, ctrl, timeouts, screenshot_dir, manifest_dir, log): """Full per-drawer sequence. Returns a STATUS_* outcome.""" log(f"Drawer {drawer_id}: starting") # 0. Dismiss any stale dialogs left from a previous failure dismiss_any_dialog(app, ctrl, log) # 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 STATUS_FAILED # 2. Open Send To File dialog try: dlg = open_send_to_file(main, app, 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 STATUS_FAILED # 3 & 4. Select -> and OK (captures manifest before clicking OK) try: manifest = perform_export(dlg, drawer_id, ctrl, timeouts, log) if manifest is not None: save_manifest(drawer_id, manifest, manifest_dir, log) except Exception as e: log(f" ERROR during export selection: {e}") take_screenshot(dlg, drawer_id, "export_fail", screenshot_dir, log) try: dlg.child_window(title="Cancel", class_name="Button").click() except Exception: pass return STATUS_FAILED # 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 STATUS_FAILED time.sleep(timeouts["settle"]) return STATUS_SUCCESS # --------------------------------------------------------------------------- # MAIN LOOP # --------------------------------------------------------------------------- def main(): args = parse_args() cfg = load_config(args.config) log = make_logger(cfg.get("paths", "log_file")) # Build lookup dicts from config paths = { "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"), } timeouts = { "nav": cfg.getfloat("timeouts", "nav_timeout"), "dialog": cfg.getfloat("timeouts", "dialog_timeout"), "select": cfg.getfloat("timeouts", "select_timeout"), "progress_appear": cfg.getfloat("timeouts", "progress_appear"), "progress_finish": cfg.getfloat("timeouts", "progress_finish"), "settle": cfg.getfloat("timeouts", "settle"), "confirm": cfg.getfloat("timeouts", "confirm_timeout"), } ctrl = { "main_class": cfg.get("controls", "main_class"), "drawer_box_title": cfg.get("controls", "drawer_box_title"), "go_button_title": cfg.get("controls", "go_button_title"), "dialog_title": cfg.get("controls", "dialog_title"), "dialog_class": cfg.get("controls", "dialog_class"), "select_btn_title": cfg.get("controls", "select_btn_title"), "ok_btn_title": cfg.get("controls", "ok_btn_title"), "progress_title": cfg.get("controls", "progress_title"), "progress_class": cfg.get("controls", "progress_class"), "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) log("FileCabinet CS batch export - run starting") log("=" * 60) all_ids = load_lines(paths["drawer_id_file"]) if not all_ids: log(f"ERROR: no drawer IDs found in {paths['drawer_id_file']}") sys.exit(1) 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 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: log("Nothing to do - all drawers already completed.") return app, main_win = connect_main(ctrl, log) try: search_box = find_search_box(main_win, ctrl, timeouts, log) except Exception as e: log(f"ERROR: {e}") sys.exit(1) 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: 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: take_screenshot(main_win, drawer_id, "unexpected", paths["screenshot_dir"], log) except Exception: pass status = STATUS_FAILED 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 / {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) log(f" Drawer {drawer_id} FAILED - will NOT be marked complete. " f"Re-run later to retry.") log("=" * 60) 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) if __name__ == "__main__": main()