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
This commit is contained in:
Adam Moussa 2026-04-22 15:07:17 -04:00
commit 53aa0b7525
9 changed files with 1392 additions and 0 deletions

23
.gitignore vendored Normal file
View file

@ -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

BIN
app_icon.icns Normal file

Binary file not shown.

BIN
app_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

157
edit_pay_dates.py Normal file
View file

@ -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()

147
generate_icon.py Normal file
View file

@ -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}")

72
scan_pending.py Normal file
View file

@ -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()

516
stampli_core.py Normal file
View file

@ -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.")

403
stampli_gui.py Normal file
View file

@ -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()

74
stampli_gui.spec Normal file
View file

@ -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',
},
},
)