diff --git a/README.md b/README.md new file mode 100644 index 0000000..21b0848 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Stampli Bulk Editor + +Mac desktop app for bulk-editing invoice pay dates in Stampli. Uses Playwright to automate the Stampli web UI through a real Chromium browser session. + +## What It Does + +Two operations, available via GUI or CLI: + +- **Edit Pay Dates** — Scans the "Ready to Pay" grid, calculates the correct pay date for each invoice (5 business days before the due date), and bulk-updates any rows that differ. After editing, optionally filters to error-free rows, selects all, and clicks Pay Invoices. +- **Scan Pending** — Reads the "Pending Approval" grid and flags invoices where the pay date doesn't match the expected calculation. + +## Setup + +```bash +# Create a virtual environment and install dependencies +uv venv && source venv/bin/activate +uv pip install -r requirements.txt + +# Install Chromium for Playwright +playwright install chromium +``` + +## Usage + +### GUI (recommended) + +```bash +python stampli_gui.py +``` + +Select an operation, optionally enable Dry Run, and click Run. The app launches a persistent Chromium browser — log in to Stampli on first use and the session is saved for future runs. + +### CLI + +```bash +# Preview changes without editing +python edit_pay_dates.py --dry-run + +# Edit pay dates +python edit_pay_dates.py + +# Only run the filter/select/pay step +python edit_pay_dates.py --filter-only + +# Interactive inspect mode (debug) +python edit_pay_dates.py --inspect + +# Scan pending approvals for incorrect dates +python scan_pending.py +``` + +## Building the Mac App + +```bash +pyinstaller stampli_gui.spec +``` + +The bundled `.app` is written to `dist/Stampli Bulk Editor.app`. It expects Playwright's Chromium to be installed at `~/Library/Caches/ms-playwright/` (the standard Playwright cache location). + +## Configuration + +Config is stored at `~/Library/Application Support/Stampli Bulk Editor/config.json`. Defaults: + +| Key | Default | Description | +|-----|---------|-------------| +| `stampli_url` | Ready to Pay dashboard URL | Starting page for edit mode | +| `date_format` | `%m/%d/%Y` | Date format used in the grid | +| `days_before_due` | `5` | Business days before due date to set pay date | + +## Project Structure + +``` +stampli_core.py # Shared logic: browser management, grid scanning, date calculation, editing +stampli_gui.py # tkinter GUI +edit_pay_dates.py # CLI for editing pay dates +scan_pending.py # CLI for scanning pending approvals +stampli_gui.spec # PyInstaller build spec (arm64 macOS) +``` diff --git a/edit_pay_dates.py b/edit_pay_dates.py index 13c1f2c..cdcf322 100644 --- a/edit_pay_dates.py +++ b/edit_pay_dates.py @@ -83,10 +83,10 @@ def bulk_edit(page, config, dry_run=False): 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}") + print(f" {'Invoice':<20s} {'Vendor':<30s} {'Amount':<12s} {'Due':<12s} {'Current Pay':<16s} {'New Pay':<12s}") + print(f" {'-'*20} {'-'*30} {'-'*12} {'-'*12} {'-'*16} {'-'*12}") for c in changes: - print(f" {c['invoice']:<20s} {c['vendor']:<30s} {c['due_text']:<12s} " + print(f" {c['invoice']:<20s} {c['vendor']:<30s} {c.get('amount', ''):<12s} {c['due_text']:<12s} " f"{c['pay_text'] or '(empty)':<16s} {c['new_pay']:<12s}") if dry_run: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..769ca9e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +playwright>=1.40 +pyinstaller>=6.0 diff --git a/stampli_core.py b/stampli_core.py index 961a568..6d1d0e7 100644 --- a/stampli_core.py +++ b/stampli_core.py @@ -1,16 +1,15 @@ """Shared logic for Stampli bulk editor — used by both CLI scripts and the GUI.""" +import csv import json +import logging import os +import re import time from datetime import datetime, timedelta from pathlib import Path -# Use system-wide Playwright browser cache so the bundled app doesn't -# look for browsers inside its own .app bundle. -_pw_cache = Path.home() / "Library" / "Caches" / "ms-playwright" -if _pw_cache.is_dir(): - os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", str(_pw_cache)) +import subprocess from playwright.sync_api import sync_playwright @@ -18,8 +17,22 @@ 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" +LOG_PATH = _APP_SUPPORT / "debug.log" + +log = logging.getLogger("stampli") +log.setLevel(logging.DEBUG) +_fh = logging.FileHandler(LOG_PATH, mode="w") +_fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")) +log.addHandler(_fh) + +CHROME_USER_DATA = Path.home() / "Library" / "Application Support" / "Google" / "Chrome" BROWSER_DATA_DIR = _APP_SUPPORT / "browser_data" +# Playwright Chromium cache for standalone mode +_pw_cache = Path.home() / "Library" / "Caches" / "ms-playwright" +if _pw_cache.is_dir(): + os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", str(_pw_cache)) + 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" @@ -34,6 +47,7 @@ EDIT_COLUMNS = { "pay": "div[data-field='invoiceRequestedDate']", "vendor": "div[data-field='vendorName']", "invoice": "div[data-field='invoiceNumber']", + "amount": "div[data-field='amountDue']", } SCAN_COLUMNS = ["invoicesNumbers", "vendorName", "dueDate", "sendPaymentOn", @@ -43,6 +57,8 @@ DEFAULT_CONFIG = { "stampli_url": READY_TO_PAY_URL, "date_format": DATE_FMT, "days_before_due": DAYS_BEFORE_DUE, + "browser_mode": "chromium", + "chrome_profile": "", } @@ -58,6 +74,38 @@ def load_config(): return dict(DEFAULT_CONFIG) +def save_config(config): + with open(CONFIG_PATH, "w") as f: + json.dump(config, f, indent=2) + + +# --------------------------------------------------------------------------- +# Chrome profile discovery +# --------------------------------------------------------------------------- + +def discover_chrome_profiles(): + """Return list of {'dir': str, 'name': str, 'email': str} for each Chrome profile.""" + profiles = [] + if not CHROME_USER_DATA.is_dir(): + return profiles + for entry in sorted(CHROME_USER_DATA.iterdir()): + prefs_file = entry / "Preferences" + if not prefs_file.exists(): + continue + dirname = entry.name + if dirname != "Default" and not dirname.startswith("Profile "): + continue + try: + prefs = json.loads(prefs_file.read_text()) + name = prefs.get("profile", {}).get("name", dirname) + accounts = prefs.get("account_info", []) + email = accounts[0].get("email", "") if accounts else "" + profiles.append({"dir": dirname, "name": name, "email": email}) + except (json.JSONDecodeError, OSError): + profiles.append({"dir": dirname, "name": dirname, "email": ""}) + return profiles + + # --------------------------------------------------------------------------- # Date helpers # --------------------------------------------------------------------------- @@ -78,17 +126,79 @@ def calc_pay_date(due_date: datetime, days_before: int = DAYS_BEFORE_DUE) -> dat return next_business_day(due_date - timedelta(days=days_before)) +def clean_amount(text: str) -> str: + """Strip unicode currency formatting down to plain number + currency code.""" + cleaned = re.sub(r"[^\d.,A-Za-z\s-]", "", text) + return re.sub(r"\s+", " ", cleaned).strip() + + # --------------------------------------------------------------------------- # Browser helpers # --------------------------------------------------------------------------- +def _chrome_is_running(): + try: + result = subprocess.run( + ["pgrep", "-x", "Google Chrome"], + capture_output=True, text=True, + ) + return result.returncode == 0 + except OSError: + return False + + +def _clean_browser_profile(data_dir, profile_subdir=None): + """Remove stale lock files and crash markers left by unclean shutdowns.""" + base = Path(data_dir) + for name in ("SingletonLock", "SingletonCookie", "SingletonSocket"): + lock = base / name + if lock.exists() or lock.is_symlink(): + lock.unlink(missing_ok=True) + + prefs_dir = base / profile_subdir if profile_subdir else base / "Default" + prefs_file = prefs_dir / "Preferences" + if prefs_file.exists(): + try: + prefs = json.loads(prefs_file.read_text()) + profile = prefs.get("profile", {}) + if profile.get("exit_type", "") != "Normal": + profile["exit_type"] = "Normal" + profile["exited_cleanly"] = True + prefs["profile"] = profile + prefs_file.write_text(json.dumps(prefs)) + except (json.JSONDecodeError, OSError): + pass + + def launch_browser(pw): - return pw.chromium.launch_persistent_context( - user_data_dir=str(BROWSER_DATA_DIR), - headless=False, - viewport={"width": 1400, "height": 900}, - args=["--disable-gpu"], - ) + config = load_config() + mode = config.get("browser_mode", "chromium") + log.info("Launching browser: mode=%s", mode) + + if mode == "chrome": + chrome_profile = config.get("chrome_profile", "Default") + log.info("Chrome profile: %s", chrome_profile) + if _chrome_is_running(): + raise RuntimeError( + "Google Chrome is currently running. " + "Please close Chrome before launching the editor." + ) + _clean_browser_profile(CHROME_USER_DATA, chrome_profile) + return pw.chromium.launch_persistent_context( + channel="chrome", + user_data_dir=str(CHROME_USER_DATA), + headless=False, + viewport={"width": 1400, "height": 900}, + args=["--disable-gpu", f"--profile-directory={chrome_profile}"], + ) + else: + _clean_browser_profile(BROWSER_DATA_DIR) + return pw.chromium.launch_persistent_context( + user_data_dir=str(BROWSER_DATA_DIR), + headless=False, + viewport={"width": 1400, "height": 900}, + args=["--disable-gpu"], + ) def active_page(browser): @@ -158,6 +268,7 @@ def collect_edit_rows(page, on_progress=None): pay_cell = row.query_selector(EDIT_COLUMNS["pay"]) vendor_cell = row.query_selector(EDIT_COLUMNS["vendor"]) inv_cell = row.query_selector(EDIT_COLUMNS["invoice"]) + amt_cell = row.query_selector(EDIT_COLUMNS["amount"]) all_rows.append({ "row_id": row_id, @@ -165,6 +276,7 @@ def collect_edit_rows(page, on_progress=None): "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 ""), + "amount": clean_amount(amt_cell.inner_text().strip().split("\n")[0]) if amt_cell else "", }) if new_this_scroll == 0: @@ -252,6 +364,38 @@ def compute_changes(all_rows, date_fmt=DATE_FMT, days_before=DAYS_BEFORE_DUE): return changes +def parse_import_csv(path, date_fmt=DATE_FMT): + """Read an exported CSV and return a changes list matched by invoice number.""" + with open(path, newline="", encoding="utf-8-sig") as f: + reader = csv.DictReader(f) + headers = reader.fieldnames or [] + + for required in ("Invoice", "New Pay"): + if required not in headers: + raise ValueError(f"CSV missing required column: {required}") + + changes = [] + for i, row in enumerate(reader, start=2): + invoice = row["Invoice"].strip() + new_pay = row["New Pay"].strip() + if not invoice or not new_pay: + continue + try: + datetime.strptime(new_pay, date_fmt) + except ValueError: + raise ValueError(f"Row {i}: invalid date '{new_pay}' (expected {date_fmt})") + changes.append({ + "row_id": row.get("Row ID", "").strip(), + "invoice": invoice, + "vendor": row.get("Vendor", "").strip(), + "amount": row.get("Amount", "").strip(), + "due_text": row.get("Due Date", "").strip(), + "pay_text": row.get("Current Pay", "").strip(), + "new_pay": new_pay, + }) + 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 = [] @@ -300,18 +444,46 @@ def find_row(page, scroller, row_id, start_scroll=0): return None, scroll_top +def _to_iso_date(date_str, date_fmt=DATE_FMT): + """Convert MM/DD/YYYY to YYYY-MM-DD for native date input .fill().""" + dt = datetime.strptime(date_str, date_fmt) + return dt.strftime("%Y-%m-%d") + + def try_edit_cell(page, row, new_date_str): pay_cell = row.query_selector(EDIT_COLUMNS["pay"]) if not pay_cell: + log.warning(" pay cell not found on row") return False + pay_text_before = pay_cell.inner_text().strip().split("\n")[0] + log.debug(" pay cell found, current value: %r", pay_text_before) + + pay_cell.scroll_into_view_if_needed() + time.sleep(0.15) + calendar_icon = pay_cell.query_selector("i.fa-calendar, [data-test-id='test-icon-calendar']") if calendar_icon: + log.debug(" clicking calendar icon") calendar_icon.click() else: + log.debug(" no calendar icon, double-clicking cell") pay_cell.dblclick() - time.sleep(0.5) + time.sleep(0.4) + + # Log what the editing cell looks like + editing_cell = page.query_selector(".MuiDataGrid-cell--editing") + if editing_cell: + all_inputs = editing_cell.query_selector_all("input") + log.debug(" editing cell has %d input(s)", len(all_inputs)) + for idx, inp in enumerate(all_inputs): + inp_info = inp.evaluate( + "el => JSON.stringify({type: el.type, value: el.value, placeholder: el.placeholder})" + ) + log.debug(" input[%d]: %s", idx, inp_info) + else: + log.warning(" no .MuiDataGrid-cell--editing found") date_input = page.query_selector( ".MuiDataGrid-cell--editing input, " @@ -324,23 +496,50 @@ def try_edit_cell(page, row, new_date_str): ) if date_input: - date_input.fill("") - date_input.fill(new_date_str) - time.sleep(0.3) - date_input.press("Enter") + input_type = date_input.evaluate("el => el.type") + log.info(" date input type=%s", input_type) + + if input_type == "date": + # Native date input: .fill() requires ISO format YYYY-MM-DD + iso_date = _to_iso_date(new_date_str) + log.info(" using .fill() with ISO date: %s", iso_date) + date_input.fill(iso_date) + else: + # Text input (Pikaday or similar): clear and type MM/DD/YYYY + log.info(" using triple-click + type for text input") + date_input.click(click_count=3) + time.sleep(0.15) + page.keyboard.type(new_date_str, delay=30) + + time.sleep(0.2) + log.debug(" pressing Enter") + page.keyboard.press("Enter") else: - page.keyboard.type(new_date_str) + log.warning(" no date input found — trying keyboard type as fallback") + page.keyboard.type(new_date_str, delay=30) + time.sleep(0.15) page.keyboard.press("Enter") - time.sleep(0.5) + time.sleep(0.4) - # Dismiss any date picker popup that may still be open page.keyboard.press("Escape") - time.sleep(0.3) + time.sleep(0.2) - # Click away from the cell to deselect it page.mouse.click(0, 0) - time.sleep(0.3) + time.sleep(0.2) + + # Verify the edit took effect + try: + pay_cell_after = row.query_selector(EDIT_COLUMNS["pay"]) + if pay_cell_after: + pay_text_after = pay_cell_after.inner_text().strip().split("\n")[0] + log.debug(" after edit: %r (wanted %r)", pay_text_after, new_date_str) + if pay_text_after != new_date_str: + log.warning(" MISMATCH: cell shows %r but wanted %r", pay_text_after, new_date_str) + else: + log.debug(" row detached after edit (expected with virtual scroll)") + except Exception: + log.debug(" row detached after edit (expected with virtual scroll)") return True @@ -361,13 +560,16 @@ def edit_single_row(page, row_id, new_date_str, last_scroll=0, max_attempts=3): return False, scroll_pos -def apply_edits(page, changes, on_progress=None): +def apply_edits(page, changes, on_progress=None, stop_check=None): """Edit rows and retry failures. Returns (success_count, failed_list).""" success = 0 failed = [] last_scroll = 0 for i, c in enumerate(changes): + if stop_check and stop_check(): + failed.extend(changes[i:]) + break try: ok, last_scroll = edit_single_row(page, c["row_id"], c["new_pay"], last_scroll) if ok: @@ -383,9 +585,12 @@ def apply_edits(page, changes, on_progress=None): if on_progress: on_progress(i + 1, len(changes), c["invoice"], False, str(e)) - if failed: + if failed and not (stop_check and stop_check()): retry_failed = [] for c in failed: + if stop_check and stop_check(): + retry_failed.extend(failed[failed.index(c):]) + break try: ok, _ = edit_single_row(page, c["row_id"], c["new_pay"], last_scroll=0) if ok: @@ -405,6 +610,213 @@ def apply_edits(page, changes, on_progress=None): return success, failed +def apply_edits_by_invoice(page, changes, on_progress=None, stop_check=None): + """Scroll through the grid sequentially, matching rows by invoice number. + + After each edit the grid re-renders, so we re-query rows at the current + scroll position instead of continuing with stale element handles. + stop_check: callable returning True if the user requested a stop. + """ + pending = {c["invoice"]: c["new_pay"] for c in changes if c.get("invoice")} + if not pending: + log.info("No invoices to process") + return 0, list(changes) + + scroller = page.query_selector(SCROLLER_SELECTOR) + if not scroller: + log.error("Scroller element not found") + return 0, list(changes) + + success = 0 + seen_invoices = set() + scroll_top = 0 + stale_count = 0 + total = len(pending) + log.info("Starting invoice-match edit: %d invoices to process", total) + + while pending: + if stop_check and stop_check(): + log.info("Stop requested by user") + break + scroller.evaluate("(el, top) => el.scrollTop = top", scroll_top) + time.sleep(0.3) + + visible_rows = page.query_selector_all(ROW_SELECTOR) + log.debug("scroll_top=%d, %d visible rows, %d pending, stale_count=%d", + scroll_top, len(visible_rows), len(pending), stale_count) + + edited_this_position = True + while edited_this_position: + if stop_check and stop_check(): + log.info("Stop requested by user (inner loop)") + break + edited_this_position = False + rows = page.query_selector_all(ROW_SELECTOR) + log.debug(" re-queried %d rows at scroll_top=%d", len(rows), scroll_top) + + visible_invoices = [] + for row in rows: + try: + inv_cell = row.query_selector(EDIT_COLUMNS["invoice"]) + if not inv_cell: + continue + invoice = inv_cell.inner_text().strip() + visible_invoices.append(invoice) + except Exception as e: + log.debug(" row read failed (detached?): %s", e) + continue + + if invoice in seen_invoices: + continue + if invoice not in pending: + seen_invoices.add(invoice) + continue + + new_pay = pending[invoice] + log.info("MATCH: invoice=%s, setting pay=%s", invoice, new_pay) + time.sleep(0.15) + + try: + if try_edit_cell(page, row, new_pay): + success += 1 + del pending[invoice] + seen_invoices.add(invoice) + if on_progress: + on_progress(success, total, invoice, True) + edited_this_position = True + log.info(" edit OK (%d/%d done, %d remaining)", success, total, len(pending)) + break + else: + seen_invoices.add(invoice) + log.warning(" edit returned False for %s", invoice) + if on_progress: + on_progress(success, total, invoice, False, "edit failed") + except Exception as e: + seen_invoices.add(invoice) + log.error(" edit exception for %s: %s", invoice, e) + if on_progress: + on_progress(success, total, invoice, False, str(e)) + page.keyboard.press("Escape") + time.sleep(0.2) + break + + if not edited_this_position: + log.debug(" no edits at scroll_top=%d, visible invoices: %s", + scroll_top, visible_invoices[:5]) + + found_new = False + for row in page.query_selector_all(ROW_SELECTOR): + try: + inv_cell = row.query_selector(EDIT_COLUMNS["invoice"]) + if inv_cell: + inv = inv_cell.inner_text().strip() + if inv and inv not in seen_invoices: + found_new = True + break + except Exception: + continue + + if found_new: + stale_count = 0 + else: + stale_count += 1 + log.debug(" no new invoices at scroll_top=%d (stale_count=%d)", scroll_top, stale_count) + if stale_count >= 3: + log.info("End of grid reached (3 consecutive stale scrolls)") + break + + scroll_top += 200 + + failed = [c for c in changes if c.get("invoice") in pending] + log.info("Finished: %d/%d succeeded, %d failed", success, total, len(failed)) + if failed: + log.info("Failed invoices: %s", [c["invoice"] for c in failed]) + return success, failed + + +# --------------------------------------------------------------------------- +# Post-edit audit +# --------------------------------------------------------------------------- + +def audit_edits(page, changes, on_progress=None, stop_check=None): + """Scroll through the grid and verify that edited invoices kept their new pay dates.""" + expected = {c["invoice"]: c["new_pay"] for c in changes if c.get("invoice")} + if not expected: + return [], [] + + scroller = page.query_selector(SCROLLER_SELECTOR) + if not scroller: + log.error("Scroller element not found for audit") + return [], list(changes) + + confirmed = [] + reverted = [] + seen = set() + scroll_top = 0 + stale_count = 0 + log.info("Audit: verifying %d invoices", len(expected)) + + while len(seen) < len(expected): + if stop_check and stop_check(): + log.info("Audit stopped by user") + break + + scroller.evaluate("(el, top) => el.scrollTop = top", scroll_top) + time.sleep(0.3) + + rows = page.query_selector_all(ROW_SELECTOR) + found_new = False + + for row in rows: + try: + inv_cell = row.query_selector(EDIT_COLUMNS["invoice"]) + if not inv_cell: + continue + invoice = inv_cell.inner_text().strip() + if not invoice or invoice in seen: + continue + + if invoice not in expected: + continue + + found_new = True + seen.add(invoice) + pay_cell = row.query_selector(EDIT_COLUMNS["pay"]) + actual = pay_cell.inner_text().strip().split("\n")[0] if pay_cell else "" + want = expected[invoice] + + if actual == want: + confirmed.append({"invoice": invoice, "expected": want, "actual": actual}) + log.debug(" AUDIT OK: %s = %s", invoice, actual) + else: + reverted.append({"invoice": invoice, "expected": want, "actual": actual}) + log.warning(" AUDIT REVERTED: %s expected=%s actual=%s", invoice, want, actual) + + if on_progress: + on_progress(len(seen), len(expected), len(reverted)) + except Exception as e: + log.debug(" audit row read failed: %s", e) + continue + + if found_new: + stale_count = 0 + else: + stale_count += 1 + if stale_count >= 3: + log.info("Audit: end of grid reached") + break + + scroll_top += 200 + + not_found = [inv for inv in expected if inv not in seen] + if not_found: + log.warning("Audit: %d invoices not found in grid: %s", len(not_found), not_found[:10]) + + log.info("Audit complete: %d confirmed, %d reverted, %d not found", + len(confirmed), len(reverted), len(not_found)) + return confirmed, reverted, not_found + + # --------------------------------------------------------------------------- # Filter / Select / Pay # --------------------------------------------------------------------------- diff --git a/stampli_gui.py b/stampli_gui.py index ea0f751..a52faae 100644 --- a/stampli_gui.py +++ b/stampli_gui.py @@ -3,6 +3,7 @@ VERSION = "1.0.0" +import csv import os import sys import queue @@ -26,7 +27,8 @@ 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, + apply_edits, apply_edits_by_invoice, audit_edits, filter_select_and_pay, + load_config, save_config, parse_import_csv, discover_chrome_profiles, READY_TO_PAY_URL, PENDING_APPROVAL_URL, DATE_FMT, ) @@ -44,6 +46,7 @@ class StampliApp: self.pw_ctx = None self._confirm_result = False self._running = False + self._stop_requested = False self._build_ui() self.root.after(100, self._poll_queue) @@ -72,10 +75,22 @@ class StampliApp: self.run_btn = ttk.Button(ctrl, text="Run", command=self.on_run) self.run_btn.pack(side="left", padx=5) + self.stop_btn = ttk.Button(ctrl, text="Stop", command=self.on_stop, state="disabled") + self.stop_btn.pack(side="left", padx=5) + + self.export_btn = ttk.Button(ctrl, text="Export CSV", command=self.on_export, + state="disabled") + self.export_btn.pack(side="left", padx=5) + + self.import_btn = ttk.Button(ctrl, text="Import CSV", command=self.on_import) + self.import_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) + ttk.Button(ctrl, text="Settings", command=self.on_settings).pack(side="right", padx=5) + # --- Status bar --- self.status_var = tk.StringVar(value="Ready") status_bar = ttk.Label(self.root, textvariable=self.status_var, relief="sunken", @@ -159,8 +174,13 @@ class StampliApp: def _on_done(self, message): self._running = False + self._stop_requested = False self.run_btn.configure(state="normal") + self.stop_btn.configure(state="disabled") + self.import_btn.configure(state="normal") self.close_btn.configure(state="normal") + if self.tree.get_children(): + self.export_btn.configure(state="normal") self.mode_var.set(self.mode_var.get()) # re-enable radios self.status_var.set(message or "Done") @@ -170,23 +190,25 @@ class StampliApp: def _populate_edit_table(self, changes): self.tree.delete(*self.tree.get_children()) - cols = ("invoice", "vendor", "due", "current_pay", "new_pay") + cols = ("invoice", "vendor", "amount", "due", "current_pay", "new_pay") self.tree["columns"] = cols self.tree.heading("invoice", text="Invoice") self.tree.heading("vendor", text="Vendor") + self.tree.heading("amount", text="Amount") 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("invoice", width=130, minwidth=80) + self.tree.column("vendor", width=220, minwidth=120) + self.tree.column("amount", width=100, minwidth=70) self.tree.column("due", width=100, minwidth=80) - self.tree.column("current_pay", width=120, minwidth=80) + self.tree.column("current_pay", width=110, 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"], + self.tree.insert("", "end", iid=c.get("row_id", ""), values=( + c["invoice"], c["vendor"], c.get("amount", ""), + c["due_text"], c["pay_text"] or "(empty)", c["new_pay"], )) def _populate_scan_table(self, results): @@ -231,15 +253,123 @@ class StampliApp: self._post("manual_fallback", (prompt, event)) event.wait() + # ------------------------------------------------------------------ + # Settings + # ------------------------------------------------------------------ + + def on_settings(self): + config = load_config() + dlg = tk.Toplevel(self.root) + dlg.title("Settings") + dlg.geometry("420x200") + dlg.resizable(False, False) + dlg.transient(self.root) + dlg.grab_set() + + frame = ttk.Frame(dlg, padding=15) + frame.pack(fill="both", expand=True) + + # Browser mode + ttk.Label(frame, text="Browser:").grid(row=0, column=0, sticky="w", pady=(0, 8)) + mode_var = tk.StringVar(value=config.get("browser_mode", "chromium")) + mode_frame = ttk.Frame(frame) + mode_frame.grid(row=0, column=1, sticky="w", pady=(0, 8)) + ttk.Radiobutton(mode_frame, text="Chromium (standalone)", + variable=mode_var, value="chromium", + command=lambda: profile_combo.configure(state="disabled")).pack(anchor="w") + ttk.Radiobutton(mode_frame, text="Chrome (existing profile)", + variable=mode_var, value="chrome", + command=lambda: profile_combo.configure(state="readonly")).pack(anchor="w") + + # Chrome profile picker + ttk.Label(frame, text="Chrome Profile:").grid(row=1, column=0, sticky="w", pady=(0, 8)) + profiles = discover_chrome_profiles() + profile_labels = [] + profile_dirs = [] + for p in profiles: + label = p["email"] or p["name"] + if p["email"] and p["name"] != p["dir"]: + label = f"{p['email']}" + profile_labels.append(f"{p['dir']} — {label}") + profile_dirs.append(p["dir"]) + + profile_var = tk.StringVar() + saved_profile = config.get("chrome_profile", "") + if saved_profile in profile_dirs: + profile_var.set(profile_labels[profile_dirs.index(saved_profile)]) + elif profile_labels: + profile_var.set(profile_labels[0]) + + profile_combo = ttk.Combobox(frame, textvariable=profile_var, + values=profile_labels, state="readonly", width=35) + profile_combo.grid(row=1, column=1, sticky="w", pady=(0, 8)) + if mode_var.get() != "chrome": + profile_combo.configure(state="disabled") + + def on_save(): + config["browser_mode"] = mode_var.get() + sel = profile_var.get() + for i, label in enumerate(profile_labels): + if label == sel: + config["chrome_profile"] = profile_dirs[i] + break + save_config(config) + self._append_log(f"Settings saved: browser={config['browser_mode']}" + + (f", profile={config.get('chrome_profile', '')}" + if config["browser_mode"] == "chrome" else "")) + dlg.destroy() + + btn_frame = ttk.Frame(frame) + btn_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0)) + ttk.Button(btn_frame, text="Save", command=on_save).pack(side="left", padx=5) + ttk.Button(btn_frame, text="Cancel", command=dlg.destroy).pack(side="left", padx=5) + # ------------------------------------------------------------------ # Run button # ------------------------------------------------------------------ + def on_export(self): + from tkinter import filedialog + cols = self.tree["columns"] + headers = ["Row ID"] + [self.tree.heading(c, "text") for c in cols] + iids = self.tree.get_children() + rows = [[iid] + list(self.tree.item(iid, "values")) for iid in iids] + if not rows: + return + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + default_name = f"stampli_export_{timestamp}.csv" + path = filedialog.asksaveasfilename( + defaultextension=".csv", + filetypes=[("CSV files", "*.csv")], + initialfile=default_name, + ) + if not path: + return + + with open(path, "w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + writer.writerow(headers) + writer.writerows(rows) + + self._append_log(f"Exported {len(rows)} rows to {path}") + self.status_var.set(f"Exported to {os.path.basename(path)}") + + def on_stop(self): + if self._running: + self._stop_requested = True + self.stop_btn.configure(state="disabled") + self._append_log("Stop requested — finishing current edit...") + self.status_var.set("Stopping...") + def on_run(self): if self._running: return self._running = True + self._stop_requested = False self.run_btn.configure(state="disabled") + self.stop_btn.configure(state="normal") + self.export_btn.configure(state="disabled") self.tree.delete(*self.tree.get_children()) self.log_text.configure(state="normal") self.log_text.delete("1.0", "end") @@ -335,10 +465,27 @@ class StampliApp: if i > 0: self._post("status", f"Editing... {i}/{total}") - success, failed = apply_edits(page, changes, on_progress=on_edit_progress) + success, failed = apply_edits(page, changes, on_progress=on_edit_progress, + stop_check=lambda: self._stop_requested) self._post("log", f"Done: {success}/{len(changes)} updated, {len(failed)} failed.") - if success > 0: + if success > 0 and not self._stop_requested: + self._post("status", "Auditing edits...") + self._post("log", "Verifying edits stuck (scrolling through grid)...") + edited = [c for c in changes if c not in failed] + + def on_audit_progress(checked, total, reverted_count): + self._post("status", f"Auditing... {checked}/{total} ({reverted_count} reverted)") + + confirmed, reverted, not_found = audit_edits( + page, edited, + on_progress=on_audit_progress, + stop_check=lambda: self._stop_requested, + ) + self._post("log", f"Audit: {len(confirmed)} confirmed, {len(reverted)} reverted, {len(not_found)} not found") + for r in reverted: + self._post("log", f" REVERTED: {r['invoice']} — expected {r['expected']}, shows {r['actual']}") + 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( @@ -366,13 +513,147 @@ class StampliApp: f" WRONG: {r['invoicesNumbers']} — " f"pay={r['sendPaymentOn']} expected={r['expected']}") + # ------------------------------------------------------------------ + # Import CSV + # ------------------------------------------------------------------ + + def on_import(self): + if self._running: + return + from tkinter import filedialog + path = filedialog.askopenfilename( + filetypes=[("CSV files", "*.csv")], + ) + if not path: + return + + try: + changes = parse_import_csv(path) + except ValueError as e: + messagebox.showerror("Import Error", str(e)) + return + + if not changes: + messagebox.showinfo("Import", "No rows found in CSV.") + return + + self._imported_changes = changes + self._post("table_edit", changes) + self._append_log(f"Imported {len(changes)} rows from {os.path.basename(path)}") + self.export_btn.configure(state="normal") + + if not messagebox.askyesno("Confirm", f"Apply {len(changes)} imported pay dates?"): + self._append_log("Import aborted by user.") + self.status_var.set("Import cancelled.") + return + + self._running = True + self._stop_requested = False + self.run_btn.configure(state="disabled") + self.stop_btn.configure(state="normal") + self.import_btn.configure(state="disabled") + self.export_btn.configure(state="disabled") + + self.worker_thread = threading.Thread( + target=self._run_import, + args=(changes,), + daemon=True, + ) + self.worker_thread.start() + + def _run_import(self, changes): + try: + if not self.browser_ctx: + self._post("status", "Launching browser...") + self._post("log", "Launching browser...") + self.pw_ctx = sync_playwright().start() + self.browser_ctx = launch_browser(self.pw_ctx) + navigate(self.browser_ctx, READY_TO_PAY_URL) + self._post("log", f"Navigating to {READY_TO_PAY_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) + self._post("status", "Scrolling through grid, matching invoices...") + self._post("log", f"Applying {len(changes)} imported pay dates (matching by invoice)...") + self._post("table_edit", changes) + + def on_edit_progress(done, total, inv, ok, info=""): + status = "Updated" if ok else "MISSED" + msg = f"[{done}/{total}] {status}: {inv}" + if info: + msg += f" ({info})" + self._post("log", msg) + self._post("status", f"Applying... {done}/{total}") + + success, failed = apply_edits_by_invoice( + page, changes, + on_progress=on_edit_progress, + stop_check=lambda: self._stop_requested, + ) + if self._stop_requested: + self._post("log", f"Stopped: {success}/{len(changes)} updated before stop.") + else: + self._post("log", f"Done: {success}/{len(changes)} updated, {len(failed)} failed.") + + if success > 0 and not self._stop_requested: + self._post("status", "Auditing edits...") + self._post("log", "Verifying edits stuck (scrolling through grid)...") + edited = [c for c in changes if c.get("invoice") not in {f["invoice"] for f in failed}] + + def on_audit_progress(checked, total, reverted_count): + self._post("status", f"Auditing... {checked}/{total} ({reverted_count} reverted)") + + confirmed, reverted, not_found = audit_edits( + page, edited, + on_progress=on_audit_progress, + stop_check=lambda: self._stop_requested, + ) + self._post("log", f"Audit: {len(confirmed)} confirmed, {len(reverted)} reverted, {len(not_found)} not found") + for r in reverted: + self._post("log", f" REVERTED: {r['invoice']} — expected {r['expected']}, shows {r['actual']}") + + 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, + ) + + 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}") + # ------------------------------------------------------------------ # Browser cleanup # ------------------------------------------------------------------ def on_close_browser(self): + # Stop any running operation first + if self._running: + self._stop_requested = True + if self.browser_ctx: try: + for pg in self.browser_ctx.pages: + try: + pg.close() + except Exception: + pass self.browser_ctx.close() except Exception: pass @@ -383,6 +664,19 @@ class StampliApp: except Exception: pass self.pw_ctx = None + + # Kill any lingering browser processes + import subprocess + try: + subprocess.run(["pkill", "-f", "chromium.*--remote-debugging"], capture_output=True, timeout=3) + except Exception: + pass + + self._running = False + self._stop_requested = False + self.run_btn.configure(state="normal") + self.stop_btn.configure(state="disabled") + self.import_btn.configure(state="normal") self.close_btn.configure(state="disabled") self.status_var.set("Browser closed.") self._append_log("Browser closed.")