commit 53aa0b75254b607595ac2c9a5453c2a39f7f24d0 Author: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed Apr 22 15:07:17 2026 -0400 Initial commit — Stampli Bulk Editor v1.0.0 Mac GUI app (tkinter + Playwright) for bulk-editing invoice pay dates on Stampli's Select to Pay dashboard. Features: - Scan MUI DataGrid rows via virtual scroll - Auto-calculate pay dates (due - 5 days, business day adjusted) - Bulk edit pay date cells via calendar input - Filter to error-free invoices, select all, submit for payment - Scan Pending Approval tab to verify edits - PyInstaller .app bundle with custom icon diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb47b16 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +venv/ +__pycache__/ +*.pyc +build/ +dist/ +*.spec.bak +browser_data/ +gui_error.log + +# HTML snapshots from debugging +*_snapshot.html +page_snapshot.html + +# Screenshots +*.png +!app_icon.png + +# macOS +.DS_Store + +# Old manual app bundle and command launcher (replaced by PyInstaller) +Stampli Bulk Editor.app/ +Stampli Bulk Editor.command diff --git a/app_icon.icns b/app_icon.icns new file mode 100644 index 0000000..fcbfc93 Binary files /dev/null and b/app_icon.icns differ diff --git a/app_icon.png b/app_icon.png new file mode 100644 index 0000000..e23a295 Binary files /dev/null and b/app_icon.png differ diff --git a/edit_pay_dates.py b/edit_pay_dates.py new file mode 100644 index 0000000..13c1f2c --- /dev/null +++ b/edit_pay_dates.py @@ -0,0 +1,157 @@ +#!/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 +import sys +from datetime import datetime +from pathlib import Path +from playwright.sync_api import sync_playwright + +from stampli_core import ( + load_config, calc_pay_date, active_page, launch_browser, navigate, + wait_for_login, needs_login, collect_edit_rows, compute_changes, + apply_edits, filter_select_and_pay, READY_TO_PAY_URL, + ROW_SELECTOR, EDIT_COLUMNS, SCROLLER_SELECTOR, +) + + +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} {'Due':<12s} {'Current Pay':<16s} {'New Pay':<12s}") + print(f" {'-'*20} {'-'*30} {'-'*12} {'-'*16} {'-'*12}") + for c in changes: + print(f" {c['invoice']:<20s} {c['vendor']:<30s} {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() diff --git a/generate_icon.py b/generate_icon.py new file mode 100644 index 0000000..be1a698 --- /dev/null +++ b/generate_icon.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Generate a macOS .icns app icon for Stampli Bulk Editor.""" + +import os +import subprocess +import tempfile +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont + +SIZE = 1024 + +def draw_icon(size=SIZE): + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + s = size / 1024 # scale factor + + # Rounded-rect background — deep navy blue + bg_color = (22, 37, 67) + margin = int(80 * s) + radius = int(180 * s) + draw.rounded_rectangle( + [margin, margin, size - margin, size - margin], + radius=radius, + fill=bg_color, + ) + + # Document shape — white, slightly offset right + doc_left = int(280 * s) + doc_top = int(180 * s) + doc_right = int(750 * s) + doc_bottom = int(820 * s) + doc_radius = int(30 * s) + draw.rounded_rectangle( + [doc_left, doc_top, doc_right, doc_bottom], + radius=doc_radius, + fill=(255, 255, 255), + ) + + # Dog-ear fold on top-right corner of document + fold_size = int(90 * s) + fold_points = [ + (doc_right - fold_size, doc_top), + (doc_right, doc_top + fold_size), + (doc_right, doc_top), + ] + draw.polygon(fold_points, fill=bg_color) + fold_triangle = [ + (doc_right - fold_size, doc_top), + (doc_right - fold_size, doc_top + fold_size), + (doc_right, doc_top + fold_size), + ] + draw.polygon(fold_triangle, fill=(200, 210, 225)) + + # Horizontal lines on document (invoice lines) + line_color = (180, 195, 215) + line_h = int(12 * s) + line_left = int(330 * s) + line_right_short = int(580 * s) + line_right_long = int(700 * s) + y_positions = [340, 400, 460, 520, 580] + for i, y in enumerate(y_positions): + y_scaled = int(y * s) + right = line_right_short if i % 2 else line_right_long + draw.rounded_rectangle( + [line_left, y_scaled, right, y_scaled + line_h], + radius=int(6 * s), + fill=line_color, + ) + + # Dollar sign — accent color (Stampli teal/green) + accent = (0, 191, 165) + dollar_x = int(380 * s) + dollar_y = int(660 * s) + dollar_size = int(110 * s) + draw.ellipse( + [dollar_x - dollar_size, dollar_y - dollar_size, + dollar_x + dollar_size, dollar_y + dollar_size], + fill=accent, + ) + try: + font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", int(130 * s)) + except (OSError, IOError): + font = ImageFont.load_default() + draw.text((dollar_x, dollar_y), "$", fill="white", font=font, anchor="mm") + + # Checkmark badge — bottom-right + badge_cx = int(680 * s) + badge_cy = int(750 * s) + badge_r = int(85 * s) + draw.ellipse( + [badge_cx - badge_r, badge_cy - badge_r, + badge_cx + badge_r, badge_cy + badge_r], + fill=(46, 204, 113), + outline=bg_color, + width=int(12 * s), + ) + check_points = [ + (int(640 * s), int(750 * s)), + (int(670 * s), int(785 * s)), + (int(730 * s), int(715 * s)), + ] + draw.line(check_points, fill="white", width=int(18 * s), joint="curve") + + # Small pencil icon — top-left area, representing "edit" + pencil_color = (255, 193, 7) + px, py = int(220 * s), int(280 * s) + pencil_body = [ + (px, py + int(60 * s)), + (px + int(15 * s), py - int(30 * s)), + (px + int(45 * s), py - int(20 * s)), + (px + int(30 * s), py + int(70 * s)), + ] + draw.polygon(pencil_body, fill=pencil_color) + pencil_tip = [ + (px, py + int(60 * s)), + (px + int(30 * s), py + int(70 * s)), + (px + int(10 * s), py + int(90 * s)), + ] + draw.polygon(pencil_tip, fill=(60, 60, 60)) + + return img + + +def make_icns(img, output_path): + """Create .icns from a PIL Image using macOS iconutil.""" + with tempfile.TemporaryDirectory() as tmpdir: + iconset = os.path.join(tmpdir, "icon.iconset") + os.makedirs(iconset) + sizes = [16, 32, 64, 128, 256, 512, 1024] + for sz in sizes: + resized = img.resize((sz, sz), Image.LANCZOS) + resized.save(os.path.join(iconset, f"icon_{sz}x{sz}.png")) + if sz <= 512: + double = img.resize((sz * 2, sz * 2), Image.LANCZOS) + double.save(os.path.join(iconset, f"icon_{sz}x{sz}@2x.png")) + subprocess.run(["iconutil", "-c", "icns", iconset, "-o", output_path], check=True) + + +if __name__ == "__main__": + project = Path(__file__).parent + icon_img = draw_icon() + png_path = project / "app_icon.png" + icon_img.save(str(png_path)) + print(f"Saved PNG: {png_path}") + icns_path = project / "app_icon.icns" + make_icns(icon_img, str(icns_path)) + print(f"Saved ICNS: {icns_path}") diff --git a/scan_pending.py b/scan_pending.py new file mode 100644 index 0000000..7fab874 --- /dev/null +++ b/scan_pending.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +""" +Scan the Stampli "Pending Approval" tab and report rows with incorrect pay dates. + +Usage: + python scan_pending.py + python scan_pending.py --snap +""" + +import argparse +import time +from pathlib import Path +from playwright.sync_api import sync_playwright + +from stampli_core import ( + launch_browser, navigate, active_page, wait_for_login, needs_login, + collect_scan_rows, compute_scan_results, PENDING_APPROVAL_URL, DATE_FMT, +) + + +def main(): + parser = argparse.ArgumentParser(description="Scan Stampli Pending Approval") + parser.add_argument("--snap", action="store_true") + args = parser.parse_args() + + with sync_playwright() as p: + browser = launch_browser(p) + navigate(browser, PENDING_APPROVAL_URL) + + time.sleep(3) + page = active_page(browser) + if needs_login(page): + if not wait_for_login(browser, on_status=print): + browser.close() + return + + page = active_page(browser) + print("Waiting for page to settle...") + time.sleep(3) + + if args.snap: + html = page.content() + snap_path = Path(__file__).parent / "pending_snapshot.html" + with open(snap_path, "w") as f: + f.write(html) + print(f"Saved snapshot to {snap_path}") + + print("\nScanning Pending Approval rows...\n") + all_rows = collect_scan_rows(page) + print(f"Found {len(all_rows)} rows\n") + + results, incorrect = compute_scan_results(all_rows) + + print(f" {'Invoice':<20s} {'Vendor':<35s} {'Due':<12s} {'Pay Date':<12s} {'Expected':<12s} {'Status'}") + print(f" {'-'*20} {'-'*35} {'-'*12} {'-'*12} {'-'*12} {'-'*8}") + for r in results: + print(f" {r['invoicesNumbers']:<20s} {r['vendorName'][:35]:<35s} " + f"{r['dueDate']:<12s} {r['sendPaymentOn']:<12s} {r['expected']:<12s} {r['status']}") + + print(f"\n{len(incorrect)} rows with incorrect pay dates out of {len(all_rows)} total.") + if incorrect: + print(f"\nIncorrect rows:") + for r in incorrect: + print(f" {r['invoicesNumbers']:<20s} {r['vendorName'][:35]:<35s} " + f"pay={r['sendPaymentOn']} (due={r['dueDate']})") + + input("\nPress Enter to close the browser...") + browser.close() + + +if __name__ == "__main__": + main() diff --git a/stampli_core.py b/stampli_core.py new file mode 100644 index 0000000..df738f1 --- /dev/null +++ b/stampli_core.py @@ -0,0 +1,516 @@ +"""Shared logic for Stampli bulk editor — used by both CLI scripts and the GUI.""" + +import json +import time +from datetime import datetime, timedelta +from pathlib import Path +from playwright.sync_api import sync_playwright + +PROJECT_DIR = Path(__file__).parent +_APP_SUPPORT = Path.home() / "Library" / "Application Support" / "Stampli Bulk Editor" +_APP_SUPPORT.mkdir(parents=True, exist_ok=True) +CONFIG_PATH = _APP_SUPPORT / "config.json" +BROWSER_DATA_DIR = _APP_SUPPORT / "browser_data" + +READY_TO_PAY_URL = "https://app.stampli.com/v265n2/dashboard.html#t=ready_to_pay" +PENDING_APPROVAL_URL = "https://app.stampli.com/v265n2/dashboard.html#t=payments_to_approve" + +DATE_FMT = "%m/%d/%Y" +DAYS_BEFORE_DUE = 5 + +ROW_SELECTOR = ".MuiDataGrid-virtualScrollerRenderZone div.MuiDataGrid-row" +SCROLLER_SELECTOR = ".MuiDataGrid-virtualScroller" + +EDIT_COLUMNS = { + "due": "div[data-field='dueDate']", + "pay": "div[data-field='invoiceRequestedDate']", + "vendor": "div[data-field='vendorName']", + "invoice": "div[data-field='invoiceNumber']", +} + +SCAN_COLUMNS = ["invoicesNumbers", "vendorName", "dueDate", "sendPaymentOn", + "paymentMethod", "amountDue", "amount"] + +DEFAULT_CONFIG = { + "stampli_url": READY_TO_PAY_URL, + "date_format": DATE_FMT, + "days_before_due": DAYS_BEFORE_DUE, +} + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +def load_config(): + if CONFIG_PATH.exists(): + with open(CONFIG_PATH) as f: + saved = json.load(f) + return {**DEFAULT_CONFIG, **saved} + return dict(DEFAULT_CONFIG) + + +# --------------------------------------------------------------------------- +# Date helpers +# --------------------------------------------------------------------------- + +def next_business_day(d: datetime) -> datetime: + weekday = d.weekday() + if weekday == 5: + d += timedelta(days=2) + elif weekday == 6: + d += timedelta(days=1) + return d + + +def calc_pay_date(due_date: datetime, days_before: int = DAYS_BEFORE_DUE) -> datetime: + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + if (due_date - today).days <= 5: + return next_business_day(today + timedelta(days=2)) + return next_business_day(due_date - timedelta(days=days_before)) + + +# --------------------------------------------------------------------------- +# Browser helpers +# --------------------------------------------------------------------------- + +def launch_browser(pw): + return pw.chromium.launch_persistent_context( + user_data_dir=str(BROWSER_DATA_DIR), + headless=False, + viewport={"width": 1400, "height": 900}, + ) + + +def active_page(browser): + pages = browser.pages + return pages[-1] if pages else browser.new_page() + + +def navigate(browser, url): + page = browser.pages[0] if browser.pages else browser.new_page() + page.goto(url, wait_until="domcontentloaded") + return page + + +def wait_for_login(browser, timeout_minutes=5, on_status=None): + if on_status: + on_status("Waiting for login...") + deadline = time.time() + timeout_minutes * 60 + while time.time() < deadline: + for pg in browser.pages: + url = pg.url.lower() + if "login" not in url and "auth" not in url and "signin" not in url: + if "stampli.com" in url: + if on_status: + on_status("Login detected.") + return True + time.sleep(2) + if on_status: + on_status("Login timed out.") + return False + + +def needs_login(page): + url = page.url.lower() + return "login" in url or "auth" in url or "signin" in url + + +# --------------------------------------------------------------------------- +# Virtual-scroll grid scanning +# --------------------------------------------------------------------------- + +def collect_edit_rows(page, on_progress=None): + """Scan the Select to Pay grid. Returns list of dicts with row_id, invoice, vendor, due_text, pay_text.""" + scroller = page.query_selector(SCROLLER_SELECTOR) + if not scroller: + return [] + + seen_ids = set() + all_rows = [] + scroll_top = 0 + stale_count = 0 + + while True: + scroller.evaluate("(el, top) => el.scrollTop = top", scroll_top) + time.sleep(0.5) + + rows = page.query_selector_all(ROW_SELECTOR) + new_this_scroll = 0 + + for row in rows: + row_id = row.get_attribute("data-id") + if not row_id or row_id in seen_ids: + continue + seen_ids.add(row_id) + new_this_scroll += 1 + + due_cell = row.query_selector(EDIT_COLUMNS["due"]) + pay_cell = row.query_selector(EDIT_COLUMNS["pay"]) + vendor_cell = row.query_selector(EDIT_COLUMNS["vendor"]) + inv_cell = row.query_selector(EDIT_COLUMNS["invoice"]) + + all_rows.append({ + "row_id": row_id, + "invoice": inv_cell.inner_text().strip() if inv_cell else "", + "vendor": (vendor_cell.inner_text().strip()[:40] if vendor_cell else ""), + "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 ""), + }) + + if new_this_scroll == 0: + stale_count += 1 + if stale_count >= 3: + break + else: + stale_count = 0 + + if on_progress: + on_progress(len(all_rows)) + + scroll_top += 200 + + return all_rows + + +def collect_scan_rows(page, on_progress=None): + """Scan the Pending Approval grid. Returns list of dicts keyed by SCAN_COLUMNS.""" + scroller = page.query_selector(SCROLLER_SELECTOR) + if not scroller: + return [] + + seen_ids = set() + all_rows = [] + scroll_top = 0 + stale_count = 0 + + while True: + scroller.evaluate("(el, top) => el.scrollTop = top", scroll_top) + time.sleep(0.5) + + rows = page.query_selector_all(ROW_SELECTOR) + new_this_scroll = 0 + + for row in rows: + row_id = row.get_attribute("data-id") + if not row_id or row_id in seen_ids: + continue + seen_ids.add(row_id) + new_this_scroll += 1 + + fields = {} + for col in SCAN_COLUMNS: + cell = row.query_selector(f"div[data-field='{col}']") + fields[col] = cell.inner_text().strip().split("\n")[0] if cell else "" + all_rows.append(fields) + + if new_this_scroll == 0: + stale_count += 1 + if stale_count >= 3: + break + else: + stale_count = 0 + + if on_progress: + on_progress(len(all_rows)) + + scroll_top += 200 + + return all_rows + + +# --------------------------------------------------------------------------- +# Change computation +# --------------------------------------------------------------------------- + +def compute_changes(all_rows, date_fmt=DATE_FMT, days_before=DAYS_BEFORE_DUE): + """Pure function: given collected rows, return list of rows that need updating.""" + changes = [] + for row_data in all_rows: + due_text = row_data["due_text"] + pay_text = row_data["pay_text"] + if not due_text: + continue + try: + due_date = datetime.strptime(due_text, date_fmt) + except ValueError: + continue + new_pay_date = calc_pay_date(due_date, days_before) + new_pay_str = new_pay_date.strftime(date_fmt) + if pay_text == new_pay_str: + continue + changes.append({**row_data, "new_pay": new_pay_str}) + return changes + + +def compute_scan_results(all_rows, date_fmt=DATE_FMT, days_before=DAYS_BEFORE_DUE): + """Pure function: compare actual vs expected pay dates. Returns (results, incorrect).""" + results = [] + incorrect = [] + for r in all_rows: + due_text = r["dueDate"] + pay_text = r["sendPaymentOn"] + try: + due_dt = datetime.strptime(due_text, date_fmt) + expected = calc_pay_date(due_dt, days_before) + expected_str = expected.strftime(date_fmt) + except ValueError: + expected_str = "???" + ok = pay_text == expected_str + entry = {**r, "expected": expected_str, "status": "OK" if ok else "WRONG"} + results.append(entry) + if not ok: + incorrect.append(entry) + return results, incorrect + + +# --------------------------------------------------------------------------- +# Editing +# --------------------------------------------------------------------------- + +def find_row(page, scroller, row_id, start_scroll=0): + row = page.query_selector( + f".MuiDataGrid-virtualScrollerRenderZone div.MuiDataGrid-row[data-id='{row_id}']" + ) + if row: + return row, start_scroll + + scroll_top = 0 + max_scroll = scroller.evaluate("el => el.scrollHeight") + + while scroll_top <= max_scroll: + scroller.evaluate("(el, top) => el.scrollTop = top", scroll_top) + time.sleep(0.3) + row = page.query_selector( + f".MuiDataGrid-virtualScrollerRenderZone div.MuiDataGrid-row[data-id='{row_id}']" + ) + if row: + return row, scroll_top + scroll_top += 200 + + return None, scroll_top + + +def try_edit_cell(page, row, new_date_str): + pay_cell = row.query_selector(EDIT_COLUMNS["pay"]) + if not pay_cell: + return False + + calendar_icon = pay_cell.query_selector("i.fa-calendar, [data-test-id='test-icon-calendar']") + if calendar_icon: + calendar_icon.click() + else: + pay_cell.dblclick() + + time.sleep(0.5) + + date_input = page.query_selector( + ".MuiDataGrid-cell--editing input, " + ".react-datepicker input, " + "input[type='date'], " + "input.date-input, " + ".datepicker input, " + "input[placeholder*='date' i], " + "input[placeholder*='MM' i]" + ) + + if date_input: + date_input.fill("") + date_input.fill(new_date_str) + time.sleep(0.3) + date_input.press("Enter") + else: + page.keyboard.type(new_date_str) + page.keyboard.press("Enter") + + time.sleep(0.5) + + # Dismiss any date picker popup that may still be open + page.keyboard.press("Escape") + time.sleep(0.3) + + # Click away from the cell to deselect it + page.mouse.click(0, 0) + time.sleep(0.3) + + return True + + +def edit_single_row(page, row_id, new_date_str, last_scroll=0, max_attempts=3): + scroller = page.query_selector(SCROLLER_SELECTOR) + for attempt in range(max_attempts): + row, scroll_pos = find_row(page, scroller, row_id, start_scroll=last_scroll) + if not row: + if attempt < max_attempts - 1: + time.sleep(0.5) + continue + return False, scroll_pos + if try_edit_cell(page, row, new_date_str): + return True, scroll_pos + page.keyboard.press("Escape") + time.sleep(0.3) + return False, scroll_pos + + +def apply_edits(page, changes, on_progress=None): + """Edit rows and retry failures. Returns (success_count, failed_list).""" + success = 0 + failed = [] + last_scroll = 0 + + for i, c in enumerate(changes): + try: + ok, last_scroll = edit_single_row(page, c["row_id"], c["new_pay"], last_scroll) + if ok: + success += 1 + if on_progress: + on_progress(i + 1, len(changes), c["invoice"], True) + else: + failed.append(c) + if on_progress: + on_progress(i + 1, len(changes), c["invoice"], False) + except Exception as e: + failed.append(c) + if on_progress: + on_progress(i + 1, len(changes), c["invoice"], False, str(e)) + + if failed: + retry_failed = [] + for c in failed: + try: + ok, _ = edit_single_row(page, c["row_id"], c["new_pay"], last_scroll=0) + if ok: + success += 1 + if on_progress: + on_progress(-1, len(failed), c["invoice"], True, "retry") + else: + retry_failed.append(c) + if on_progress: + on_progress(-1, len(failed), c["invoice"], False, "retry failed") + except Exception as e: + retry_failed.append(c) + if on_progress: + on_progress(-1, len(failed), c["invoice"], False, str(e)) + failed = retry_failed + + return success, failed + + +# --------------------------------------------------------------------------- +# Filter / Select / Pay +# --------------------------------------------------------------------------- + +def filter_select_and_pay(page, on_status=None, on_manual_fallback=None): + """Apply 'No errors' filter, select all, click Pay Invoices, click Google auth.""" + def status(msg): + if on_status: + on_status(msg) + + status("Opening filter drawer...") + filter_toggle = page.query_selector("button[data-test-id='selectToPay-filter-toggle']") + if not filter_toggle: + status("ERROR: Could not find filter toggle button.") + return + filter_toggle.click() + time.sleep(1) + + applied = False + try: + status_input = page.query_selector( + "div[data-test-id='selectToPay-filter-filterStatuses'] input[placeholder='Status']" + ) + if not status_input: + view_all = page.query_selector( + "div[data-test-id='selectToPay-filter-menu'] >> text=View all" + ) + if view_all: + view_all.click() + time.sleep(0.5) + status_input = page.query_selector( + "div[data-test-id='selectToPay-filter-filterStatuses'] input[placeholder='Status']" + ) + + if status_input: + status_input.evaluate("el => el.scrollIntoView({block: 'center', behavior: 'instant'})") + time.sleep(0.3) + status_input.click(force=True) + time.sleep(0.8) + + no_errors = page.query_selector(".MuiAutocomplete-listbox >> text=No errors") + if not no_errors: + no_errors = page.query_selector(".MuiAutocomplete-listbox >> text=No Errors") + if not no_errors: + options = page.query_selector_all(".MuiAutocomplete-option") + for opt in options: + if "no error" in opt.inner_text().strip().lower(): + no_errors = opt + break + + if no_errors: + no_errors.click() + time.sleep(0.5) + applied = True + status("Applied 'No errors' filter.") + except Exception: + pass + + if not applied: + status("Could not auto-apply filter.") + if on_manual_fallback: + on_manual_fallback("Please apply Status -> 'No errors' manually in the browser, then click OK.") + else: + input(" Apply the filter manually, then press Enter: ") + + close_btn = page.query_selector( + "div[data-test-id='selectToPay-filter-menu-header'] button:last-child" + ) + if close_btn: + close_btn.click() + time.sleep(0.5) + + time.sleep(2) + + rows = page.query_selector_all(ROW_SELECTOR) + status(f"{len(rows)} rows visible after filtering.") + + status("Selecting all rows...") + select_all = page.query_selector("input[aria-label='Select all rows']") + if not select_all: + select_all = page.query_selector(".MuiDataGrid-columnHeaderCheckbox .MuiCheckbox-root") + if select_all: + select_all.click() + time.sleep(1) + status("Selected all rows.") + else: + status("ERROR: Could not find Select All checkbox.") + return + + status("Clicking Pay Invoices...") + pay_button = page.query_selector("button[data-test-id='selectToPay-action-payInvoices']") + if not pay_button: + status("ERROR: Could not find Pay Invoices button.") + return + + is_disabled = pay_button.get_attribute("disabled") + if is_disabled is not None: + status("Pay Invoices button disabled — no rows selected.") + if on_manual_fallback: + on_manual_fallback("Please select rows manually, then click OK.") + else: + input(" Select rows manually, then press Enter: ") + pay_button = page.query_selector("button[data-test-id='selectToPay-action-payInvoices']") + + pay_button.click() + time.sleep(2) + status("Clicked Pay Invoices.") + + status("Looking for re-authentication modal...") + google_btn = page.query_selector("button >> text=Google") + if not google_btn: + google_btn = page.query_selector("text=Google") + if google_btn: + google_btn.click() + status("Clicked Google login.") + time.sleep(3) + else: + status("No re-auth modal found.") diff --git a/stampli_gui.py b/stampli_gui.py new file mode 100644 index 0000000..ea0f751 --- /dev/null +++ b/stampli_gui.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +"""Stampli Bulk Editor — tkinter GUI.""" + +VERSION = "1.0.0" + +import os +import sys +import queue +import threading +import time +import traceback +from datetime import datetime + +# Set Tcl/Tk library paths before importing tkinter +_py_base = os.path.dirname(os.path.dirname(os.path.realpath(sys.executable))) +for _var, _subdir in [("TCL_LIBRARY", "tcl8.6"), ("TK_LIBRARY", "tk8.6")]: + _path = os.path.join(_py_base, "lib", _subdir) + if os.path.isdir(_path) and _var not in os.environ: + os.environ[_var] = _path + +import tkinter as tk +from tkinter import ttk, messagebox + +from playwright.sync_api import sync_playwright + +from stampli_core import ( + launch_browser, navigate, active_page, wait_for_login, needs_login, + collect_edit_rows, collect_scan_rows, compute_changes, compute_scan_results, + apply_edits, filter_select_and_pay, load_config, + READY_TO_PAY_URL, PENDING_APPROVAL_URL, DATE_FMT, +) + + +class StampliApp: + def __init__(self, root): + self.root = root + self.root.title(f"Stampli Bulk Editor v{VERSION}") + self.root.geometry("1000x700") + self.root.minsize(800, 500) + + self.msg_queue = queue.Queue() + self.worker_thread = None + self.browser_ctx = None + self.pw_ctx = None + self._confirm_result = False + self._running = False + + self._build_ui() + self.root.after(100, self._poll_queue) + + # Bring window to front on macOS + self.root.lift() + self.root.attributes("-topmost", True) + self.root.after(500, lambda: self.root.attributes("-topmost", False)) + + def _build_ui(self): + # --- Top controls --- + ctrl = ttk.Frame(self.root, padding=10) + ctrl.pack(fill="x") + + ttk.Label(ctrl, text="Operation:").pack(side="left") + self.mode_var = tk.StringVar(value="edit") + ttk.Radiobutton(ctrl, text="Edit Pay Dates", variable=self.mode_var, + value="edit").pack(side="left", padx=(5, 10)) + ttk.Radiobutton(ctrl, text="Scan Pending", variable=self.mode_var, + value="scan").pack(side="left", padx=(0, 20)) + + self.dry_run_var = tk.BooleanVar(value=False) + self.dry_run_cb = ttk.Checkbutton(ctrl, text="Dry Run", variable=self.dry_run_var) + self.dry_run_cb.pack(side="left", padx=(0, 20)) + + self.run_btn = ttk.Button(ctrl, text="Run", command=self.on_run) + self.run_btn.pack(side="left", padx=5) + + self.close_btn = ttk.Button(ctrl, text="Close Browser", command=self.on_close_browser, + state="disabled") + self.close_btn.pack(side="left", padx=5) + + # --- Status bar --- + self.status_var = tk.StringVar(value="Ready") + status_bar = ttk.Label(self.root, textvariable=self.status_var, relief="sunken", + padding=(10, 4)) + status_bar.pack(fill="x", padx=10) + + # --- Paned window for table + log --- + pane = ttk.PanedWindow(self.root, orient="vertical") + pane.pack(fill="both", expand=True, padx=10, pady=(5, 10)) + + # Table frame + table_frame = ttk.Frame(pane) + pane.add(table_frame, weight=3) + + self.tree = ttk.Treeview(table_frame, show="headings", selectmode="none") + tree_scroll_y = ttk.Scrollbar(table_frame, orient="vertical", command=self.tree.yview) + tree_scroll_x = ttk.Scrollbar(table_frame, orient="horizontal", command=self.tree.xview) + self.tree.configure(yscrollcommand=tree_scroll_y.set, xscrollcommand=tree_scroll_x.set) + tree_scroll_y.pack(side="right", fill="y") + tree_scroll_x.pack(side="bottom", fill="x") + self.tree.pack(fill="both", expand=True) + + self.tree.tag_configure("wrong", foreground="#cc0000") + self.tree.tag_configure("ok", foreground="#006600") + + # Log frame + log_frame = ttk.Frame(pane) + pane.add(log_frame, weight=1) + + self.log_text = tk.Text(log_frame, height=8, state="disabled", wrap="word", + font=("Menlo", 11)) + log_scroll = ttk.Scrollbar(log_frame, orient="vertical", command=self.log_text.yview) + self.log_text.configure(yscrollcommand=log_scroll.set) + log_scroll.pack(side="right", fill="y") + self.log_text.pack(fill="both", expand=True) + + # ------------------------------------------------------------------ + # Queue polling + # ------------------------------------------------------------------ + + def _poll_queue(self): + while True: + try: + msg_type, payload = self.msg_queue.get_nowait() + except queue.Empty: + break + + if msg_type == "status": + self.status_var.set(payload) + elif msg_type == "log": + self._append_log(payload) + elif msg_type == "table_edit": + self._populate_edit_table(payload) + elif msg_type == "table_scan": + self._populate_scan_table(payload) + elif msg_type == "confirm": + prompt, event = payload + self._show_confirm(prompt, event) + elif msg_type == "manual_fallback": + prompt, event = payload + self._show_manual_fallback(prompt, event) + elif msg_type == "done": + self._on_done(payload) + + self.root.after(100, self._poll_queue) + + def _append_log(self, text): + ts = datetime.now().strftime("%H:%M:%S") + self.log_text.configure(state="normal") + self.log_text.insert("end", f"[{ts}] {text}\n") + self.log_text.see("end") + self.log_text.configure(state="disabled") + + def _show_confirm(self, prompt, event): + self._confirm_result = messagebox.askyesno("Confirm", prompt) + event.set() + + def _show_manual_fallback(self, prompt, event): + messagebox.showinfo("Manual Step Required", prompt) + event.set() + + def _on_done(self, message): + self._running = False + self.run_btn.configure(state="normal") + self.close_btn.configure(state="normal") + self.mode_var.set(self.mode_var.get()) # re-enable radios + self.status_var.set(message or "Done") + + # ------------------------------------------------------------------ + # Table population + # ------------------------------------------------------------------ + + def _populate_edit_table(self, changes): + self.tree.delete(*self.tree.get_children()) + cols = ("invoice", "vendor", "due", "current_pay", "new_pay") + self.tree["columns"] = cols + self.tree.heading("invoice", text="Invoice") + self.tree.heading("vendor", text="Vendor") + self.tree.heading("due", text="Due Date") + self.tree.heading("current_pay", text="Current Pay") + self.tree.heading("new_pay", text="New Pay") + self.tree.column("invoice", width=140, minwidth=80) + self.tree.column("vendor", width=250, minwidth=120) + self.tree.column("due", width=100, minwidth=80) + self.tree.column("current_pay", width=120, minwidth=80) + self.tree.column("new_pay", width=100, minwidth=80) + + for c in changes: + self.tree.insert("", "end", values=( + c["invoice"], c["vendor"], c["due_text"], + c["pay_text"] or "(empty)", c["new_pay"], + )) + + def _populate_scan_table(self, results): + self.tree.delete(*self.tree.get_children()) + cols = ("invoice", "vendor", "due", "pay_date", "expected", "status") + self.tree["columns"] = cols + self.tree.heading("invoice", text="Invoice") + self.tree.heading("vendor", text="Vendor") + self.tree.heading("due", text="Due Date") + self.tree.heading("pay_date", text="Pay Date") + self.tree.heading("expected", text="Expected") + self.tree.heading("status", text="Status") + self.tree.column("invoice", width=140, minwidth=80) + self.tree.column("vendor", width=220, minwidth=120) + self.tree.column("due", width=90, minwidth=80) + self.tree.column("pay_date", width=90, minwidth=80) + self.tree.column("expected", width=90, minwidth=80) + self.tree.column("status", width=60, minwidth=50) + + for r in results: + tag = "wrong" if r["status"] == "WRONG" else "ok" + self.tree.insert("", "end", values=( + r["invoicesNumbers"], r["vendorName"][:35], r["dueDate"], + r["sendPaymentOn"], r["expected"], r["status"], + ), tags=(tag,)) + + # ------------------------------------------------------------------ + # Worker helpers + # ------------------------------------------------------------------ + + def _post(self, msg_type, payload=None): + self.msg_queue.put((msg_type, payload)) + + def _ask_confirm(self, prompt): + event = threading.Event() + self._post("confirm", (prompt, event)) + event.wait() + return self._confirm_result + + def _manual_fallback(self, prompt): + event = threading.Event() + self._post("manual_fallback", (prompt, event)) + event.wait() + + # ------------------------------------------------------------------ + # Run button + # ------------------------------------------------------------------ + + def on_run(self): + if self._running: + return + self._running = True + self.run_btn.configure(state="disabled") + self.tree.delete(*self.tree.get_children()) + self.log_text.configure(state="normal") + self.log_text.delete("1.0", "end") + self.log_text.configure(state="disabled") + + mode = self.mode_var.get() + dry_run = self.dry_run_var.get() + + self.worker_thread = threading.Thread( + target=self._worker, + args=(mode, dry_run), + daemon=True, + ) + self.worker_thread.start() + + def _worker(self, mode, dry_run): + try: + self._post("status", "Launching browser...") + self._post("log", "Launching browser...") + + self.pw_ctx = sync_playwright().start() + self.browser_ctx = launch_browser(self.pw_ctx) + + url = READY_TO_PAY_URL if mode == "edit" else PENDING_APPROVAL_URL + navigate(self.browser_ctx, url) + + self._post("log", f"Navigating to {url}") + time.sleep(3) + + page = active_page(self.browser_ctx) + if needs_login(page): + self._post("status", "Waiting for login...") + self._post("log", "Please log in to Stampli in the browser window.") + if not wait_for_login(self.browser_ctx, on_status=lambda m: self._post("log", m)): + self._post("done", "Login timed out.") + return + + self._post("log", "Waiting for page to settle...") + time.sleep(3) + page = active_page(self.browser_ctx) + + if mode == "edit": + self._run_edit(page, dry_run) + else: + self._run_scan(page) + + self._post("done", "Complete — browser still open. Click 'Close Browser' when done.") + self.root.after(0, lambda: self.close_btn.configure(state="normal")) + + except Exception as e: + self._post("log", f"ERROR: {e}") + self._post("log", traceback.format_exc()) + self._post("done", f"Error: {e}") + + def _run_edit(self, page, dry_run): + config = load_config() + date_fmt = config["date_format"] + days_before = config["days_before_due"] + + self._post("status", "Scanning rows...") + self._post("log", "Scanning all rows...") + + all_rows = collect_edit_rows( + page, + on_progress=lambda n: self._post("status", f"Scanning rows... ({n} found)"), + ) + self._post("log", f"Found {len(all_rows)} total rows.") + + changes = compute_changes(all_rows, date_fmt, days_before) + if not changes: + self._post("log", "All rows already have correct pay dates.") + return + + self._post("table_edit", changes) + label = "DRY RUN: " if dry_run else "" + self._post("log", f"{label}{len(changes)} rows need updating.") + + if dry_run: + return + + if not self._ask_confirm(f"Proceed with {len(changes)} updates?"): + self._post("log", "Aborted by user.") + return + + self._post("status", "Editing rows...") + + def on_edit_progress(i, total, inv, ok, info=""): + if i == -1: + msg = f"RETRY {'OK' if ok else 'FAIL'}: {inv} {info}" + else: + msg = f"[{i}/{total}] {'Updated' if ok else 'MISSED'}: {inv} {info}" + self._post("log", msg) + if i > 0: + self._post("status", f"Editing... {i}/{total}") + + success, failed = apply_edits(page, changes, on_progress=on_edit_progress) + self._post("log", f"Done: {success}/{len(changes)} updated, {len(failed)} failed.") + + if success > 0: + if self._ask_confirm("Filter to error-free rows, select all, and click Pay Invoices?"): + self._post("status", "Running filter/select/pay...") + filter_select_and_pay( + page, + on_status=lambda m: (self._post("log", m), self._post("status", m)), + on_manual_fallback=self._manual_fallback, + ) + + def _run_scan(self, page): + self._post("status", "Scanning rows...") + self._post("log", "Scanning Pending Approval rows...") + + all_rows = collect_scan_rows( + page, + on_progress=lambda n: self._post("status", f"Scanning rows... ({n} found)"), + ) + self._post("log", f"Found {len(all_rows)} rows.") + + results, incorrect = compute_scan_results(all_rows) + self._post("table_scan", results) + + self._post("log", f"{len(incorrect)} incorrect out of {len(all_rows)} total.") + for r in incorrect: + self._post("log", + f" WRONG: {r['invoicesNumbers']} — " + f"pay={r['sendPaymentOn']} expected={r['expected']}") + + # ------------------------------------------------------------------ + # Browser cleanup + # ------------------------------------------------------------------ + + def on_close_browser(self): + if self.browser_ctx: + try: + self.browser_ctx.close() + except Exception: + pass + self.browser_ctx = None + if self.pw_ctx: + try: + self.pw_ctx.stop() + except Exception: + pass + self.pw_ctx = None + self.close_btn.configure(state="disabled") + self.status_var.set("Browser closed.") + self._append_log("Browser closed.") + + def on_quit(self): + self.on_close_browser() + self.root.destroy() + + +def main(): + root = tk.Tk() + app = StampliApp(root) + root.protocol("WM_DELETE_WINDOW", app.on_quit) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/stampli_gui.spec b/stampli_gui.spec new file mode 100644 index 0000000..73b31a3 --- /dev/null +++ b/stampli_gui.spec @@ -0,0 +1,74 @@ +# -*- mode: python ; coding: utf-8 -*- +import os +import sys + +uv_python = os.path.realpath(os.path.join('venv', 'bin', 'python')) +uv_base = os.path.dirname(os.path.dirname(uv_python)) +tcl_dir = os.path.join(uv_base, 'lib', 'tcl8.6') +tk_dir = os.path.join(uv_base, 'lib', 'tk8.6') + +# Find playwright driver +import playwright +pw_dir = os.path.dirname(playwright.__file__) +pw_driver = os.path.join(pw_dir, 'driver') + +a = Analysis( + ['stampli_gui.py'], + pathex=[], + binaries=[], + datas=[ + ('stampli_core.py', '.'), + ('app_icon.icns', '.'), + (tcl_dir, 'tcl8.6'), + (tk_dir, 'tk8.6'), + (pw_driver, 'playwright/driver'), + ], + hiddenimports=['playwright', 'playwright.sync_api', 'greenlet'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='Stampli Bulk Editor', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + target_arch='arm64', + icon='app_icon.icns', +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + name='Stampli Bulk Editor', +) + +app = BUNDLE( + coll, + name='Stampli Bulk Editor.app', + bundle_identifier='com.seahaven.stampli-bulk-editor', + icon='app_icon.icns', + info_plist={ + 'NSHighResolutionCapable': True, + 'CFBundleShortVersionString': '1.0.0', + 'CFBundleVersion': '1.0.0', + 'LSEnvironment': { + 'TCL_LIBRARY': '../Resources/tcl8.6', + 'TK_LIBRARY': '../Resources/tk8.6', + }, + }, +)