mirror of
https://github.com/Sea-Haven-Industries/stampli-bulk-editor.git
synced 2026-09-30 04:33:11 +00:00
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:
commit
53aa0b7525
9 changed files with 1392 additions and 0 deletions
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
BIN
app_icon.icns
Normal file
Binary file not shown.
BIN
app_icon.png
Normal file
BIN
app_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
157
edit_pay_dates.py
Normal file
157
edit_pay_dates.py
Normal 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
147
generate_icon.py
Normal 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
72
scan_pending.py
Normal 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
516
stampli_core.py
Normal 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
403
stampli_gui.py
Normal 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
74
stampli_gui.spec
Normal 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',
|
||||
},
|
||||
},
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue