Files
FCCS/fccs_export.py

500 lines
19 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, 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()