""" 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 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 "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, 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 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"), "ignore_file": cfg.get("paths", "ignore_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"]) ignored = set(load_lines(paths["ignore_file"])) pending = [d for d in all_ids if d not in completed and d not in ignored] 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"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()