#!/usr/bin/env python3 """ Stampli Bulk Pay Date Editor (CLI) Usage: python edit_pay_dates.py --dry-run python edit_pay_dates.py python edit_pay_dates.py --filter-only python edit_pay_dates.py --inspect """ import argparse from pathlib import Path from playwright.sync_api import sync_playwright from stampli_core import ( load_config, active_page, launch_browser, navigate, wait_for_login, needs_login, collect_edit_rows, compute_changes, apply_edits, filter_select_and_pay, ROW_SELECTOR, EDIT_COLUMNS, ) def inspect_mode(browser): print("\n" + "=" * 60) print("INSPECT MODE") print("=" * 60) print(""" Type 'snap' to save page HTML, 'scan' to preview rows, 'done' to exit. """) while True: cmd = input(">>> ").strip().lower() page = active_page(browser) try: if cmd == "snap": html = page.content() snap_path = Path(__file__).parent / "page_snapshot.html" with open(snap_path, "w") as f: f.write(html) print(f"Saved page HTML to {snap_path}") elif cmd == "scan": rows = page.query_selector_all(ROW_SELECTOR) print(f"Found {len(rows)} rendered rows (virtual scroll)") for i, row in enumerate(rows[:10]): due_cell = row.query_selector(EDIT_COLUMNS["due"]) pay_cell = row.query_selector(EDIT_COLUMNS["pay"]) due_text = ( due_cell.inner_text().strip().split("\n")[0] if due_cell else "?" ) pay_text = ( pay_cell.inner_text().strip().split("\n")[0] if pay_cell else "?" ) vendor_cell = row.query_selector(EDIT_COLUMNS["vendor"]) vendor = ( vendor_cell.inner_text().strip()[:30] if vendor_cell else "?" ) inv_cell = row.query_selector(EDIT_COLUMNS["invoice"]) inv = inv_cell.inner_text().strip()[:20] if inv_cell else "?" print( f" Row {i}: inv={inv} vendor={vendor} due={due_text} pay={pay_text}" ) elif cmd == "columns": headers = page.query_selector_all("[role='columnheader']") for i, h in enumerate(headers): field = h.get_attribute("data-field") or "" text = h.inner_text().strip().replace("\n", " ")[:40] print(f" Col {i}: field={field:30s} label='{text}'") elif cmd in ("done", "quit", "exit", ""): break else: print("Commands: snap, scan, columns, done") except Exception as e: print(f"Error (page may have changed): {e}") def bulk_edit(page, config, dry_run=False): date_fmt = config["date_format"] days_before = config["days_before_due"] print("\nScanning all rows (scrolling through virtual grid)...") all_rows = collect_edit_rows(page, on_progress=lambda n: None) print(f"Found {len(all_rows)} total rows") changes = compute_changes(all_rows, date_fmt, days_before) if not changes: print("\nAll rows already have correct pay dates. Nothing to do.") return print(f"\n{'DRY RUN: ' if dry_run else ''}{len(changes)} rows to update:\n") print( f" {'Invoice':<20s} {'Vendor':<30s} {'Amount':<12s} {'Due':<12s} {'Current Pay':<16s} {'New Pay':<12s}" ) print(f" {'-' * 20} {'-' * 30} {'-' * 12} {'-' * 12} {'-' * 16} {'-' * 12}") for c in changes: print( f" {c['invoice']:<20s} {c['vendor']:<30s} {c.get('amount', ''):<12s} {c['due_text']:<12s} " f"{c['pay_text'] or '(empty)':<16s} {c['new_pay']:<12s}" ) if dry_run: return confirm = input(f"\nProceed with {len(changes)} updates? [y/N]: ").strip().lower() if confirm != "y": print("Aborted.") return def on_progress(i, total, inv, ok, info=""): if i == -1: prefix = " RETRY" else: prefix = f" [{i}/{total}]" status = "Updated" if ok else "MISSED" print(f"{prefix} {status} {inv} {info}") success, failed = apply_edits(page, changes, on_progress=on_progress) print(f"\nDone: {success}/{len(changes)} rows updated.") if success > 0: submit = ( input( "\nFilter to error-free rows, select all, and click Pay Invoices? [y/N]: " ) .strip() .lower() ) if submit == "y": filter_select_and_pay(page, on_status=print) def main(): parser = argparse.ArgumentParser(description="Stampli Bulk Pay Date Editor") parser.add_argument("--inspect", action="store_true") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--filter-only", action="store_true") parser.add_argument("--url", type=str) args = parser.parse_args() config = load_config() url = args.url or config["stampli_url"] with sync_playwright() as p: browser = launch_browser(p) navigate(browser, url) import time time.sleep(3) page = active_page(browser) if needs_login(page): if not wait_for_login(browser, on_status=print): browser.close() return print("Waiting for page to settle...") time.sleep(3) if args.inspect: inspect_mode(browser) elif args.filter_only: page = active_page(browser) filter_select_and_pay(page, on_status=print) else: page = active_page(browser) bulk_edit(page, config, dry_run=args.dry_run) input("\nPress Enter to close the browser...") browser.close() if __name__ == "__main__": main()