#!/usr/bin/env python3 """ batch.py - Robust folder batch driver for the docx-to-md skill. Converts every .docx/.doc in a folder by invoking convert.py once PER FILE in its own subprocess, guarded by a per-file timeout. If a single document makes Word hang, that file is killed and skipped instead of blocking the whole batch. Each convert.py call performs the source-file move on success, so a killed file is left in place for a later retry. Progress is written to --log (flushed per line); a JSON summary to --result. Usage: python batch.py --input-dir "" --out "" \ --processed-dir "/Processed" \ --log "" --result "" [--timeout 180] [--skip-existing] Safety: records pre-existing WINWORD.EXE PIDs before each file and, on timeout, kills ONLY the instance spawned for that file (never the user's open Word). """ import argparse import csv import datetime import io import json import os import subprocess import sys import time EXTS = (".docx", ".doc") def winword_pids(): try: out = subprocess.run( ["tasklist", "/FI", "IMAGENAME eq WINWORD.EXE", "/FO", "CSV", "/NH"], capture_output=True, text=True, timeout=30).stdout except Exception: return set() pids = set() for row in csv.reader(io.StringIO(out)): if len(row) >= 2 and row[1].isdigit(): pids.add(int(row[1])) return pids def kill_pid(pid): try: subprocess.run(["taskkill", "/PID", str(pid), "/F", "/T"], capture_output=True, text=True, timeout=30) except Exception: pass def main(): ap = argparse.ArgumentParser(description="Robust folder batch driver for docx-to-md.") ap.add_argument("--input-dir", required=True) ap.add_argument("--out", required=True) ap.add_argument("--processed-dir", default="") ap.add_argument("--log", required=True) ap.add_argument("--result", required=True) ap.add_argument("--timeout", type=int, default=180, help="Per-file timeout in seconds.") ap.add_argument("--skip-existing", action="store_true") args = ap.parse_args() convert_py = os.path.join(os.path.dirname(os.path.abspath(__file__)), "convert.py") os.makedirs(args.out, exist_ok=True) # Non-recursive scan; never descend into the Processed folder. files = sorted( f for f in os.listdir(args.input_dir) if f.lower().endswith(EXTS) and not f.startswith("~$") and os.path.isfile(os.path.join(args.input_dir, f))) total = len(files) logf = open(args.log, "w", encoding="utf-8", buffering=1) def log(msg): stamp = datetime.datetime.now().strftime("%H:%M:%S") logf.write("[%s] %s\n" % (stamp, msg)) logf.flush() log("BATCH START: %d files, timeout=%ds" % (total, args.timeout)) log("source: %s" % args.input_dir) log("output: %s" % args.out) if args.processed_dir: log("processed: %s" % args.processed_dir) processed, errors, skipped = [], [], [] for i, name in enumerate(files, start=1): src = os.path.join(args.input_dir, name) out_md = os.path.join(args.out, os.path.splitext(name)[0] + ".md") if args.skip_existing and os.path.exists(out_md) and os.path.getsize(out_md) > 0: skipped.append(name) log("[%d/%d] skip (exists): %s" % (i, total, name)) continue log("[%d/%d] start: %s" % (i, total, name)) baseline = winword_pids() cmd = [sys.executable, convert_py, "--input", src, "--out", args.out] if args.processed_dir: cmd += ["--processed-dir", args.processed_dir] t0 = time.time() proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) try: out_s, _err_s = proc.communicate(timeout=args.timeout) dt = time.time() - t0 summary = None try: summary = json.loads(out_s) except Exception: summary = None if summary and summary.get("processed"): rec = summary["processed"][0] processed.append({"file": name, "method": rec.get("method"), "moved": bool(rec.get("moved_to"))}) moved = " -> moved" if rec.get("moved_to") else "" log("[%d/%d] done: %s (%s, %.0fs)%s" % (i, total, name, rec.get("method"), dt, moved)) else: msg = "" if summary and summary.get("errors"): msg = summary["errors"][0].get("error", "") errors.append({"file": name, "error": "no output; %s" % msg}) log("[%d/%d] ERROR: %s -> %s" % (i, total, name, msg)) except subprocess.TimeoutExpired: proc.kill() try: proc.communicate(timeout=15) except Exception: pass orphans = winword_pids() - baseline for pid in orphans: kill_pid(pid) errors.append({"file": name, "error": "timeout after %ds" % args.timeout}) log("[%d/%d] TIMEOUT: %s (killed %d orphan Word proc)" % (i, total, name, len(orphans))) time.sleep(2) summary = { "total": total, "converted": len(processed), "errors": errors, "skipped": skipped, "processed": processed, } with open(args.result, "w", encoding="utf-8") as f: json.dump(summary, f, indent=2) log("BATCH COMPLETE: %d converted, %d errors, %d skipped (of %d)" % (len(processed), len(errors), len(skipped), total)) logf.close() return 0 if __name__ == "__main__": sys.exit(main())