Compare commits

..

35 commits
v1.0.0 ... main

Author SHA1 Message Date
Adam Moussa
040f7db2df
docs(agents): drop security review gates (#34)
Agents no longer treat a security review or a cross-family review as a merge gate.
2026-09-26 17:18:23 -04:00
Adam Moussa
d819370d58
chore(ci): remove unused Mergify stub (#33) 2026-09-22 18:41:44 +00:00
Adam Moussa
4266f159bd
chore(deps): remove dependabot version updates (#32)
Renovate is the version-update bot. GitHub Dependabot alerts stay.
2026-08-25 11:51:36 -04:00
dependabot[bot]
d57a50c1da
chore(deps): update pyinstaller requirement from >=6.22.0 to >=6.22.2 (#25) 2026-08-25 00:14:12 +00:00
dependabot[bot]
47133d4482
chore(deps): bump the minor-and-patch group across 1 directory with 3 updates (#29) 2026-08-24 19:49:41 +00:00
Adam Moussa
4441025263
chore(ci): remove pr policy workflow caller (#28) 2026-08-24 15:13:03 -04:00
Adam Moussa
578b61291f
chore(ci): switch auto-merge from seahaven-bot to Mergify (#27) 2026-08-24 13:52:55 -04:00
Adam Moussa
5a6d2bd337
ci: enable squash auto-merge on ready PRs (PLAT-108) (#23)
* ci: enable squash auto-merge on ready PRs

* fix(ci): serialize auto-merge enable and ignore already-enabled
2026-08-21 23:34:49 +00:00
Adam Moussa
682c6b9e7f
ci: add merge_group trigger for required ci / ci (#22) 2026-08-21 17:42:43 -04:00
dependabot[bot]
01158da49e
chore(deps): update pyinstaller requirement from >=6.21.0 to >=6.22.0 (#21)
Updates the requirements on [pyinstaller](https://github.com/pyinstaller/pyinstaller) to permit the latest version.
- [Release notes](https://github.com/pyinstaller/pyinstaller/releases)
- [Changelog](https://github.com/pyinstaller/pyinstaller/blob/develop/doc/CHANGES.rst)
- [Commits](https://github.com/pyinstaller/pyinstaller/compare/v6.21.0...v6.22.0)

---
updated-dependencies:
- dependency-name: pyinstaller
  dependency-version: 6.22.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
2026-08-17 18:58:54 +00:00
dependabot[bot]
5eed812fc7
chore(deps): bump the minor-and-patch group with 4 updates (#20)
Bumps the minor-and-patch group with 4 updates: [Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml](https://github.com/sea-haven-industries/.github), [Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml](https://github.com/sea-haven-industries/.github), [Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml](https://github.com/sea-haven-industries/.github) and [Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml](https://github.com/sea-haven-industries/.github).


Updates `Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml` from 1.0.6 to 1.0.7
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](7ac3528750...e5691d8a7f)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml` from 1.0.6 to 1.0.7
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](7ac3528750...e5691d8a7f)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml` from 1.0.6 to 1.0.7
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](7ac3528750...e5691d8a7f)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml` from 1.0.6 to 1.0.7
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](7ac3528750...e5691d8a7f)

---
updated-dependencies:
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 14:58:11 -04:00
dependabot[bot]
9ca9407dab
chore(deps): bump the minor-and-patch group with 4 updates (#19)
Bumps the minor-and-patch group with 4 updates: [Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml](https://github.com/sea-haven-industries/.github), [Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml](https://github.com/sea-haven-industries/.github), [Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml](https://github.com/sea-haven-industries/.github) and [Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml](https://github.com/sea-haven-industries/.github).


Updates `Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml` from 1.0.3 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](3f74677422...7ac3528750)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml` from 1.0.3 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](3f74677422...7ac3528750)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml` from 1.0.3 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](3f74677422...7ac3528750)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml` from 1.0.5 to 1.0.6
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](9c1ecf9428...7ac3528750)

---
updated-dependencies:
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-pr-policy.yaml
  dependency-version: 1.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 14:56:49 -04:00
dependabot[bot]
89850aba1a
chore(deps): update playwright requirement from >=1.61.0 to >=1.62.0 (#18)
Updates the requirements on [playwright](https://github.com/microsoft/playwright-python) to permit the latest version.
- [Release notes](https://github.com/microsoft/playwright-python/releases)
- [Commits](https://github.com/microsoft/playwright-python/compare/v1.61.0...v1.62.0)

---
updated-dependencies:
- dependency-name: playwright
  dependency-version: 1.62.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 18:26:36 -04:00
Adam Moussa
53342c13ff
ci: add org PR policy caller (#17)
Refs: PLAT-62
2026-08-04 11:56:46 -04:00
Adam Moussa
29787ad02d
Merge pull request #16 from Sea-Haven-Industries/dependabot/github_actions/minor-and-patch-3e1a0502ab
chore(deps): bump the minor-and-patch group with 3 updates
2026-08-03 13:00:31 -04:00
dependabot[bot]
5bfd824b53
chore(deps): bump the minor-and-patch group with 3 updates
Bumps the minor-and-patch group with 3 updates: [Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml](https://github.com/sea-haven-industries/.github), [Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml](https://github.com/sea-haven-industries/.github) and [Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml](https://github.com/sea-haven-industries/.github).


Updates `Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

Updates `Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml` from 1.0.2 to 1.0.3
- [Release notes](https://github.com/sea-haven-industries/.github/releases)
- [Commits](0170a57c0d...3f74677422)

---
updated-dependencies:
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml
  dependency-version: 1.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 05:15:48 +00:00
Adam Moussa
2e52e28944
Merge pull request #15 from Sea-Haven-Industries/fix/audit-ci-workflow
ci: switch to ci-python-app, this repo has no SAM template
2026-07-28 18:40:53 -04:00
Adam Moussa
c832f798e4 ci: switch to ci-python-app, this repo has no SAM template 2026-07-28 18:37:39 -04:00
Adam Moussa
420a7661ee
Merge pull request #14 from Sea-Haven-Industries/ci/pin-reusables-v1.0.2
ci(deps): pin org reusable workflows to v1.0.2
2026-07-28 18:11:58 -04:00
Adam Moussa
411dd9fea1 ci: declare workflow permissions and normalize block spacing 2026-07-28 18:08:09 -04:00
Adam Moussa
b8cb084200 ci(deps): pin org reusable workflows to v1.0.2 2026-07-28 17:58:22 -04:00
Adam Moussa
3434211b70
chore(ci): SHA-pin org reusable-workflow caller refs (INFRA-50) (#13) 2026-07-06 18:27:11 -04:00
dependabot[bot]
a7e837b3a0
Update playwright requirement from >=1.60.0 to >=1.61.0 (#12) 2026-07-06 08:08:38 -04:00
dependabot[bot]
a58f1662ca
Update pyinstaller requirement from >=6.20.0 to >=6.21.0 (#11)
Updates the requirements on [pyinstaller](https://github.com/pyinstaller/pyinstaller) to permit the latest version.
- [Release notes](https://github.com/pyinstaller/pyinstaller/releases)
- [Changelog](https://github.com/pyinstaller/pyinstaller/blob/develop/doc/CHANGES.rst)
- [Commits](https://github.com/pyinstaller/pyinstaller/compare/v6.20.0...v6.21.0)

---
updated-dependencies:
- dependency-name: pyinstaller
  dependency-version: 6.21.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 13:30:17 -04:00
dependabot[bot]
51f1f59cdd
Update pyinstaller requirement from >=6.0 to >=6.20.0 (#10)
Updates the requirements on [pyinstaller](https://github.com/pyinstaller/pyinstaller) to permit the latest version.
- [Release notes](https://github.com/pyinstaller/pyinstaller/releases)
- [Changelog](https://github.com/pyinstaller/pyinstaller/blob/develop/doc/CHANGES.rst)
- [Commits](https://github.com/pyinstaller/pyinstaller/compare/v6.0.0...v6.20.0)

---
updated-dependencies:
- dependency-name: pyinstaller
  dependency-version: 6.20.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-11 14:34:42 -04:00
dependabot[bot]
a1253d0651
Update playwright requirement from >=1.40 to >=1.60.0 (#9)
Updates the requirements on [playwright](https://github.com/microsoft/playwright-python) to permit the latest version.
- [Release notes](https://github.com/microsoft/playwright-python/releases)
- [Commits](https://github.com/microsoft/playwright-python/compare/v1.40.0...v1.60.0)

---
updated-dependencies:
- dependency-name: playwright
  dependency-version: 1.60.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-11 14:31:51 -04:00
Adam Moussa
9ee0784aa5
Repo hygiene: PR labeler + README badges + dependabot (INFRA-56/57/66) (#8) 2026-06-11 14:14:22 -04:00
Adam Moussa
51ed9c4066
Add dependency-review caller workflow (#6)
* Add dependency-review caller workflow

Add a pull_request-triggered caller that invokes the org-level
callable-dependency-review workflow to scan dependency changes and
fail on high-severity advisories.

* chore: retrigger checks

* chore: retrigger dep review (post-fix)

* chore: add .env to .gitignore
2026-06-05 12:34:12 -04:00
Adam Moussa
d0709a0f50
Merge pull request #4 from Sea-Haven-Industries/feature/add-ci-workflow
Add CI workflow
2026-05-08 15:55:34 -04:00
Adam Moussa
f3fee37e80 Add CI workflow and apply ruff formatting 2026-05-08 15:44:35 -04:00
Adam Moussa
0a75d4e7ce
Merge pull request #3 from Sea-Haven-Industries/feature/bulk-edit-improvements
Add CSV import/export, settings, stop button, and post-edit audit
2026-05-08 10:28:38 -04:00
Adam Moussa
b737a5aa80 Add CSV import/export, Chrome profile support, settings, stop button, and post-edit audit
- CSV export with Row ID and Amount columns (UTF-8 BOM for Excel)
- CSV import: reimport edited pay dates, apply by invoice matching
- Chrome profile support alongside Chromium persistent context
- Settings dialog for browser mode and Chrome profile selection
- Stop button to halt mid-run (skips filter/pay prompt)
- Sequential invoice-matching edit flow for virtual scroll grid
- Triple-click + type for Pikaday text date inputs
- Post-edit audit pass to catch silently reverted edits
- Debug logging to ~/Library/Application Support/Stampli Bulk Editor/
- Close Browser fully kills all pages and lingering processes
- Optimized edit timing (~3 sec/edit, down from ~6 sec)
2026-05-08 09:43:46 -04:00
Adam Moussa
215f16a150 Add README and requirements.txt 2026-05-07 11:18:30 -04:00
Adam Moussa
4a7416b769 Add --disable-gpu flag to Playwright browser launch
Stampli dashboard hangs in the Playwright-managed Chromium due to the
GPU process (SwiftShader software renderer) pegging CPU at ~135%.
Disabling GPU acceleration works around the issue but does not fully
resolve the hang — further investigation needed (see issue).
2026-04-29 13:14:54 -04:00
Adam Moussa
1789eae0b5 Fix bundled app failing to find Chromium browser
Set PLAYWRIGHT_BROWSERS_PATH to ~/Library/Caches/ms-playwright/ so the
PyInstaller .app uses the system-wide browser cache instead of looking
inside its own bundle.
2026-04-22 15:10:07 -04:00
12 changed files with 1283 additions and 116 deletions

15
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,15 @@
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

10
.github/workflows/dependency-review.yml vendored Normal file
View file

@ -0,0 +1,10 @@
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

13
.github/workflows/labeler.yml vendored Normal file
View file

@ -0,0 +1,13 @@
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,3 +21,6 @@ page_snapshot.html
# Old manual app bundle and command launcher (replaced by PyInstaller)
Stampli Bulk Editor.app/
Stampli Bulk Editor.command
.env
.env.*

38
AGENTS.md Normal file
View file

@ -0,0 +1,38 @@
# 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.

81
README.md Normal file
View file

@ -0,0 +1,81 @@
# 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,16 +10,22 @@ 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, 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,
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,
)
@ -47,13 +53,25 @@ 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):
@ -83,11 +101,15 @@ 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} "
f"{c['pay_text'] or '(empty)':<16s} {c['new_pay']:<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:
return
@ -109,7 +131,13 @@ 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)
@ -130,6 +158,7 @@ def main():
navigate(browser, url)
import time
time.sleep(3)
page = active_page(browser)
if needs_login(page):

View file

@ -9,6 +9,7 @@ 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)
@ -73,8 +74,12 @@ 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:
@ -88,8 +93,12 @@ 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),
@ -133,7 +142,9 @@ 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__":

2
requirements.txt Normal file
View file

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

View file

@ -13,8 +13,14 @@ 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,
launch_browser,
navigate,
active_page,
wait_for_login,
needs_login,
collect_scan_rows,
compute_scan_results,
PENDING_APPROVAL_URL,
)
@ -51,18 +57,26 @@ 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(f"\nIncorrect rows:")
print("\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,19 +1,43 @@
"""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
from playwright.sync_api import sync_playwright
import subprocess
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
@ -26,15 +50,25 @@ 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": "",
}
@ -42,6 +76,7 @@ DEFAULT_CONFIG = {
# Config
# ---------------------------------------------------------------------------
def load_config():
if CONFIG_PATH.exists():
with open(CONFIG_PATH) as f:
@ -50,10 +85,44 @@ 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:
@ -70,16 +139,81 @@ 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},
)
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):
@ -120,6 +254,7 @@ 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)
@ -149,14 +284,26 @@ 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 ""),
})
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 "",
}
)
if new_this_scroll == 0:
stale_count += 1
@ -223,6 +370,7 @@ 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 = []
@ -243,6 +391,42 @@ 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 = []
@ -268,6 +452,7 @@ 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}']"
@ -291,18 +476,48 @@ 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
calendar_icon = pay_cell.query_selector("i.fa-calendar, [data-test-id='test-icon-calendar']")
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, "
@ -315,23 +530,54 @@ 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
@ -352,15 +598,20 @@ 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)
ok, last_scroll = edit_single_row(
page, c["row_id"], c["new_pay"], last_scroll
)
if ok:
success += 1
if on_progress:
@ -374,9 +625,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:
@ -386,7 +640,9 @@ def apply_edits(page, changes, on_progress=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:
@ -396,18 +652,264 @@ 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
# ---------------------------------------------------------------------------
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
@ -431,14 +933,20 @@ 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:
@ -457,7 +965,9 @@ 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: ")
@ -476,7 +986,9 @@ 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)
@ -486,7 +998,9 @@ 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
@ -498,7 +1012,9 @@ 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,8 +1,7 @@
#!/usr/bin/env python3
"""Stampli Bulk Editor — tkinter GUI."""
VERSION = "1.0.0"
import csv
import os
import sys
import queue
@ -11,6 +10,8 @@ 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")]:
@ -18,16 +19,31 @@ 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
from tkinter import ttk, messagebox
import tkinter as tk # noqa: E402
from tkinter import ttk, messagebox # noqa: E402
from playwright.sync_api import sync_playwright
from playwright.sync_api import sync_playwright # noqa: E402
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,
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,
)
@ -44,6 +60,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)
@ -60,26 +77,49 @@ 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.close_btn = ttk.Button(ctrl, text="Close Browser", command=self.on_close_browser,
state="disabled")
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",
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 ---
@ -91,9 +131,15 @@ 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)
@ -105,9 +151,12 @@ 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)
@ -159,8 +208,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,24 +224,35 @@ 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):
self.tree.delete(*self.tree.get_children())
@ -208,10 +273,19 @@ 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
@ -231,15 +305,146 @@ 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")
@ -273,7 +478,9 @@ 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
@ -286,7 +493,10 @@ 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:
@ -335,11 +545,46 @@ class StampliApp:
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.")
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 self._ask_confirm("Filter to error-free rows, select all, and click Pay Invoices?"):
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(
page,
@ -362,17 +607,189 @@ 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']}")
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}")
# ------------------------------------------------------------------
# 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 +800,24 @@ 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.")