Files
FCCS/fccs_export.py

458 lines
17 KiB
Python
Executable File

"""
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, 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"),
"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()