Compare commits

..

No commits in common. "main" and "v1.0.0" have entirely different histories.
main ... v1.0.0

12 changed files with 116 additions and 1283 deletions

View file

@ -1,15 +0,0 @@
name: CI
on:
pull_request:
branches: [main]
merge_group:
permissions:
contents: read
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
with:
source-dirs: "."
collect-only: false

View file

@ -1,10 +0,0 @@
name: Dependency Review
on:
pull_request:
permissions:
contents: read
jobs:
review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8

View file

@ -1,13 +0,0 @@
name: Labeler
on:
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8

3
.gitignore vendored
View file

@ -21,6 +21,3 @@ page_snapshot.html
# Old manual app bundle and command launcher (replaced by PyInstaller)
Stampli Bulk Editor.app/
Stampli Bulk Editor.command
.env
.env.*

View file

@ -1,38 +0,0 @@
# Sea Haven Governance
**Standards authority:** engineering-handbook · **Status authority:** Jira
## Routing
- Product / feature work → DEV
- Infrastructure and platform → PLAT
- Security → SEC
## Branches
`feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `release/` + kebab-case description.
No Jira keys in branch names.
## Pull Requests
**Title:** `type(scope): description (DEV-123)` — every non-exempt PR ends with its Jira key.
**Body sections (in order):** Summary · Validation · Tests · Notes — use "None." when a section is empty.
State verifiable facts only. Do not cite the handbook to justify changes.
Allowed types: `feat` `fix` `docs` `style` `refactor` `perf` `test` `build` `ci` `chore` `revert` `release`.
## CI and SHA Pins
Pin every GitHub Actions ref to a full commit SHA with an inline version comment:
```yaml
uses: actions/checkout@abc123def456 # v4.1.0
```
The deterministic global pre-push security hook must not be bypassed (`--no-verify` requires
explicit approval). Linting stays in CI; do not gate on it locally.
## Repository Note
Generic governance applies. Route product work to DEV, platform/infra to PLAT.

View file

@ -1,81 +0,0 @@
# Stampli Bulk Editor
![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white)
![CI](https://github.com/Sea-Haven-Industries/stampli-bulk-editor/actions/workflows/ci.yaml/badge.svg)
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)
```

View file

@ -10,22 +10,16 @@ Usage:
"""
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,
active_page,
launch_browser,
navigate,
wait_for_login,
needs_login,
collect_edit_rows,
compute_changes,
apply_edits,
filter_select_and_pay,
ROW_SELECTOR,
EDIT_COLUMNS,
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,
)
@ -53,25 +47,13 @@ Type 'snap' to save page HTML, 'scan' to preview rows, 'done' to exit.
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 "?"
)
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 "?"
)
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}"
)
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):
@ -101,15 +83,11 @@ 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} {'Amount':<12s} {'Due':<12s} {'Current Pay':<16s} {'New Pay':<12s}"
)
print(f" {'-' * 20} {'-' * 30} {'-' * 12} {'-' * 12} {'-' * 16} {'-' * 12}")
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.get('amount', ''):<12s} {c['due_text']:<12s} "
f"{c['pay_text'] or '(empty)':<16s} {c['new_pay']:<12s}"
)
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
@ -131,13 +109,7 @@ def bulk_edit(page, config, dry_run=False):
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()
)
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)
@ -158,7 +130,6 @@ def main():
navigate(browser, url)
import time
time.sleep(3)
page = active_page(browser)
if needs_login(page):

View file

@ -9,7 +9,6 @@ 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)
@ -74,12 +73,8 @@ def draw_icon(size=SIZE):
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,
],
[dollar_x - dollar_size, dollar_y - dollar_size,
dollar_x + dollar_size, dollar_y + dollar_size],
fill=accent,
)
try:
@ -93,12 +88,8 @@ def draw_icon(size=SIZE):
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,
],
[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),
@ -142,9 +133,7 @@ def make_icns(img, output_path):
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
)
subprocess.run(["iconutil", "-c", "icns", iconset, "-o", output_path], check=True)
if __name__ == "__main__":

View file

@ -1,2 +0,0 @@
playwright>=1.62.0
pyinstaller>=6.22.2

View file

@ -13,14 +13,8 @@ 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,
launch_browser, navigate, active_page, wait_for_login, needs_login,
collect_scan_rows, compute_scan_results, PENDING_APPROVAL_URL, DATE_FMT,
)
@ -57,26 +51,18 @@ def main():
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}")
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" {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."
)
print(f"\n{len(incorrect)} rows with incorrect pay dates out of {len(all_rows)} total.")
if incorrect:
print("\nIncorrect rows:")
print(f"\nIncorrect rows:")
for r in incorrect:
print(
f" {r['invoicesNumbers']:<20s} {r['vendorName'][:35]:<35s} "
f"pay={r['sendPaymentOn']} (due={r['dueDate']})"
)
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()

View file

@ -1,43 +1,19 @@
"""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
import subprocess
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"
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"
)
PENDING_APPROVAL_URL = "https://app.stampli.com/v265n2/dashboard.html#t=payments_to_approve"
DATE_FMT = "%m/%d/%Y"
DAYS_BEFORE_DUE = 5
@ -50,25 +26,15 @@ 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",
"paymentMethod",
"amountDue",
"amount",
]
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,
"browser_mode": "chromium",
"chrome_profile": "",
}
@ -76,7 +42,6 @@ DEFAULT_CONFIG = {
# Config
# ---------------------------------------------------------------------------
def load_config():
if CONFIG_PATH.exists():
with open(CONFIG_PATH) as f:
@ -85,44 +50,10 @@ 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
# ---------------------------------------------------------------------------
def next_business_day(d: datetime) -> datetime:
weekday = d.weekday()
if weekday == 5:
@ -139,81 +70,16 @@ 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):
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"],
)
return pw.chromium.launch_persistent_context(
user_data_dir=str(BROWSER_DATA_DIR),
headless=False,
viewport={"width": 1400, "height": 900},
)
def active_page(browser):
@ -254,7 +120,6 @@ def needs_login(page):
# 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)
@ -284,26 +149,14 @@ 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,
"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 ""
),
"amount": clean_amount(amt_cell.inner_text().strip().split("\n")[0])
if amt_cell
else "",
}
)
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
@ -370,7 +223,6 @@ def collect_scan_rows(page, on_progress=None):
# 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 = []
@ -391,42 +243,6 @@ 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 = []
@ -452,7 +268,6 @@ def compute_scan_results(all_rows, date_fmt=DATE_FMT, days_before=DAYS_BEFORE_DU
# 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}']"
@ -476,48 +291,18 @@ 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']"
)
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.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")
time.sleep(0.5)
date_input = page.query_selector(
".MuiDataGrid-cell--editing input, "
@ -530,54 +315,23 @@ def try_edit_cell(page, row, new_date_str):
)
if date_input:
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")
date_input.fill("")
date_input.fill(new_date_str)
time.sleep(0.3)
date_input.press("Enter")
else:
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.type(new_date_str)
page.keyboard.press("Enter")
time.sleep(0.4)
time.sleep(0.5)
# Dismiss any date picker popup that may still be open
page.keyboard.press("Escape")
time.sleep(0.2)
time.sleep(0.3)
# Click away from the cell to deselect it
page.mouse.click(0, 0)
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)")
time.sleep(0.3)
return True
@ -598,20 +352,15 @@ 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, stop_check=None):
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):
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
)
ok, last_scroll = edit_single_row(page, c["row_id"], c["new_pay"], last_scroll)
if ok:
success += 1
if on_progress:
@ -625,12 +374,9 @@ def apply_edits(page, changes, on_progress=None, stop_check=None):
if on_progress:
on_progress(i + 1, len(changes), c["invoice"], False, str(e))
if failed and not (stop_check and stop_check()):
if failed:
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:
@ -640,9 +386,7 @@ def apply_edits(page, changes, on_progress=None, stop_check=None):
else:
retry_failed.append(c)
if on_progress:
on_progress(
-1, len(failed), c["invoice"], False, "retry failed"
)
on_progress(-1, len(failed), c["invoice"], False, "retry failed")
except Exception as e:
retry_failed.append(c)
if on_progress:
@ -652,264 +396,18 @@ def apply_edits(page, changes, on_progress=None, stop_check=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
# ---------------------------------------------------------------------------
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']"
)
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
@ -933,20 +431,14 @@ def filter_select_and_pay(page, on_status=None, on_manual_fallback=None):
)
if status_input:
status_input.evaluate(
"el => el.scrollIntoView({block: 'center', behavior: 'instant'})"
)
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"
)
no_errors = page.query_selector(".MuiAutocomplete-listbox >> text=No errors")
if not no_errors:
no_errors = page.query_selector(
".MuiAutocomplete-listbox >> text=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:
@ -965,9 +457,7 @@ def filter_select_and_pay(page, on_status=None, on_manual_fallback=None):
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."
)
on_manual_fallback("Please apply Status -> 'No errors' manually in the browser, then click OK.")
else:
input(" Apply the filter manually, then press Enter: ")
@ -986,9 +476,7 @@ def filter_select_and_pay(page, on_status=None, on_manual_fallback=None):
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"
)
select_all = page.query_selector(".MuiDataGrid-columnHeaderCheckbox .MuiCheckbox-root")
if select_all:
select_all.click()
time.sleep(1)
@ -998,9 +486,7 @@ def filter_select_and_pay(page, on_status=None, on_manual_fallback=None):
return
status("Clicking Pay Invoices...")
pay_button = page.query_selector(
"button[data-test-id='selectToPay-action-payInvoices']"
)
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
@ -1012,9 +498,7 @@ def filter_select_and_pay(page, on_status=None, on_manual_fallback=None):
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 = page.query_selector("button[data-test-id='selectToPay-action-payInvoices']")
pay_button.click()
time.sleep(2)

View file

@ -1,7 +1,8 @@
#!/usr/bin/env python3
"""Stampli Bulk Editor — tkinter GUI."""
import csv
VERSION = "1.0.0"
import os
import sys
import queue
@ -10,8 +11,6 @@ import time
import traceback
from datetime import datetime
VERSION = "1.0.0"
# 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")]:
@ -19,31 +18,16 @@ for _var, _subdir in [("TCL_LIBRARY", "tcl8.6"), ("TK_LIBRARY", "tk8.6")]:
if os.path.isdir(_path) and _var not in os.environ:
os.environ[_var] = _path
import tkinter as tk # noqa: E402
from tkinter import ttk, messagebox # noqa: E402
import tkinter as tk
from tkinter import ttk, messagebox
from playwright.sync_api import sync_playwright # noqa: E402
from playwright.sync_api import sync_playwright
from stampli_core import ( # noqa: E402
launch_browser,
navigate,
active_page,
wait_for_login,
needs_login,
collect_edit_rows,
collect_scan_rows,
compute_changes,
compute_scan_results,
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,
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,
)
@ -60,7 +44,6 @@ 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)
@ -77,49 +60,26 @@ class StampliApp:
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))
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 = 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.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 = 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", padding=(10, 4)
)
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 ---
@ -131,15 +91,9 @@ class StampliApp:
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 = 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)
@ -151,12 +105,9 @@ class StampliApp:
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 = 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)
@ -208,13 +159,8 @@ 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")
@ -224,35 +170,24 @@ class StampliApp:
def _populate_edit_table(self, changes):
self.tree.delete(*self.tree.get_children())
cols = ("invoice", "vendor", "amount", "due", "current_pay", "new_pay")
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("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=130, minwidth=80)
self.tree.column("vendor", width=220, minwidth=120)
self.tree.column("amount", width=100, minwidth=70)
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=110, 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",
iid=c.get("row_id", ""),
values=(
c["invoice"],
c["vendor"],
c.get("amount", ""),
c["due_text"],
c["pay_text"] or "(empty)",
c["new_pay"],
),
)
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())
@ -273,19 +208,10 @@ class StampliApp:
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,),
)
self.tree.insert("", "end", values=(
r["invoicesNumbers"], r["vendorName"][:35], r["dueDate"],
r["sendPaymentOn"], r["expected"], r["status"],
), tags=(tag,))
# ------------------------------------------------------------------
# Worker helpers
@ -305,146 +231,15 @@ 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")
@ -478,9 +273,7 @@ class StampliApp:
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)
):
if not wait_for_login(self.browser_ctx, on_status=lambda m: self._post("log", m)):
self._post("done", "Login timed out.")
return
@ -493,10 +286,7 @@ class StampliApp:
else:
self._run_scan(page)
self._post(
"done",
"Complete — browser still open. Click 'Close Browser' when done.",
)
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:
@ -545,46 +335,11 @@ class StampliApp:
if i > 0:
self._post("status", f"Editing... {i}/{total}")
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."
)
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 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?"
):
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,
@ -607,189 +362,17 @@ class StampliApp:
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']}",
)
# ------------------------------------------------------------------
# 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}")
self._post("log",
f" WRONG: {r['invoicesNumbers']} — "
f"pay={r['sendPaymentOn']} expected={r['expected']}")
# ------------------------------------------------------------------
# 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
@ -800,24 +383,6 @@ 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.")