""" 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 # --------------------------------------------------------------------------- # 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 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.wait("visible ready", timeout=timeouts["nav"]) 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, log): """Read the list of selected documents from the right-side ListView.""" try: lv = dlg.child_window(title="List1", class_name="SysListView32") count = lv.item_count() if count == 0: log(" WARNING: ListView is empty after Select") return [] items = lv.texts() log(f" Manifest captured: {count} documents selected") return items except Exception as e: log(f" WARNING: could not read manifest from ListView: {e}") return None 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, ctrl, timeouts, log): """Click Select -> then OK to start the export. Returns manifest list.""" 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, 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 "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 wait_for_export(app, ctrl, timeouts, log): """ 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 """ 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 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. deadline = time.time() + timeouts["progress_finish"] saved_win = None while time.time() < deadline: 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" 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 True def export_drawer(app, main, search_box, drawer_id, ctrl, timeouts, screenshot_dir, manifest_dir, log): """Full per-drawer sequence. Returns True on success.""" 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 False # 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 False # 3 & 4. Select -> and OK (captures manifest before clicking OK) try: manifest = perform_export(dlg, 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 False # 5. Monitor progress ok = wait_for_export(app, ctrl, timeouts, log) if not ok: take_screenshot(main, drawer_id, "progress_fail", screenshot_dir, log) return False time.sleep(timeouts["settle"]) return True # --------------------------------------------------------------------------- # 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"), "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"), } 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"]) pending = [d for d in all_ids if d not in completed] log(f"Total drawers in list : {len(all_ids)}") log(f"Already completed : {len(completed)}") 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 failed_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) 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 ok = False if ok: 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)") 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}") if failed_ids: log(f" Failed drawer IDs: {', '.join(failed_ids)}") log(" These were NOT marked complete; re-running the script " "will retry them.") log("=" * 60) if __name__ == "__main__": main()