Files
bggpipe/docs/bgg-upload-flow.md
T
Eric WagonerandClaude Fable 5 0af9ae86c5 Updates go through the collection cell: collid- and version_id-exact
The last unverified flow failed on its guess — the collection row has no
link named "own"; the edit affordances are icon anchors with no text. The
site offers something far better, now verified: the row's VERSION CELL
carries its own collid in an onclick, and the inline editor it opens is a
radio list whose values ARE version ids.

So an update addresses the copy by collid and the edition by version id —
no name matching, no pagination, no dialog, and structurally incapable of
creating a duplicate entry (it sets one field on one collid). Clicking
the radio fires CE_SaveData itself; there is no Save button, and the save
has landed when the cell stops reading "Editing". A version id the editor
doesn't offer aborts with the entry untouched.

The class docstring's UNVERIFIED list is now empty but for the
second-copy add, which --verify already reports as a copy-count
shortfall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 23:15:39 -04:00

170 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# BGG "Add to Collection" flow — recon notes for the upload stage
Recorded 2026-08-01 by walking the flow manually on boardgamegeek.com (Wingspan,
id 266192) while logged in. Dialog opened, inspected, and **cancelled without
saving**. These notes are the selector documentation the spec requires before
automating Stage 5.
## Entry point
- Game page has **two** "Add To" buttons (one in the game header module, one
lower on the page) — target the first, but match by accessible name, not
position. Button's accessible name is "Add To" with adjacent text
"Collection".
- Clicking opens a `role="dialog"` containing a `<form>`. Dialog heading shows
a "Loading..." span before content settles — **wait for the game-name
heading** (e.g. `getByRole('heading', {name: gameName})`) before
interacting.
## Main dialog structure
- **Status checkboxes**, each wrapped in a `<label>`: Own, Prev. Owned,
For Trade, Want to Play, Want in Trade, Want to Buy, Pre-ordered, Wishlist.
`dialog.getByLabel('Own')` and check it. Nothing is pre-checked.
- Rating: a 110 slider plus a "Rating" text input. We do not set ratings.
- Comment (public) textbox — unused by us.
- "Advanced (private info, parts exchange)" expander: Price Paid, Current
Price, Quantity, Acquisition Date, Acquired From, Inventory Date/Location,
Private Comment, Want/Has Parts. All unused (we don't track provenance).
- "Customize Item Info (title, image)" expander: Custom Title, Custom Image
Id, plus **manual version-override fields** (Publisher Id, Language select,
Year, Other, Barcode). These are for defining a custom version — do NOT use
them; always pick a cataloged version instead (or none).
- Footer: `Save` (`type="submit"`) and `Cancel` buttons.
## Version picker ("Set version/edition")
- Button labeled **"Set version/edition"** near the top of the dialog swaps
the dialog content to a "Versions" sub-view (same `role="dialog"`).
- The sub-view is a **paginated list with NO search/filter box**. Each
listitem's text is the full canonical version name + year, e.g.
"Flügelschlag (German fifth edition) (2024)" — these names match the
version names returned by `/thing?id=X&versions=1`.
- Selection strategy: resolve the target version NAME from the XML API,
then page through the list matching listitem text
(`getByRole('listitem').filter({hasText: versionName})`). Newest years
appear first.
- The sub-view has its own **Cancel** that returns to the main dialog — it
is a different button from the main dialog's Cancel. Two-level dismissal.
## Automation gotchas observed
1. **Element references go stale constantly.** The page re-renders after
load and after every dialog transition; clicks on cached handles silently
miss. Playwright's auto-waiting role/label locators handle this — never
cache element handles across a dialog state change.
2. **Dialog persists in the DOM after cancel**, just hidden
(`offsetParent === null`). "Is the dialog gone" checks must test
visibility, not existence. Same applies when verifying a save completed.
3. First click on "Add To" right after page load can no-op (hydration race).
Wait for network-idle or the button's stable state before clicking.
4. Verify saves via the collection API (`--verify`), not by UI state.
## Playwright locator sketch
```python
page.get_by_role("button", name="Add To").first.click()
dialog = page.get_by_role("dialog")
dialog.get_by_role("heading", name=game_name).wait_for()
dialog.get_by_label("Own").check()
if version_name:
dialog.get_by_role("button", name="Set version/edition").click()
# page through listitems until version_name matches, then click it
dialog.get_by_role("button", name="Save").click()
```
Unverified so far (needs a real, sacrificial save on one game before batch
runs): pagination controls in the version sub-view, exact post-save behavior
(toast? dialog close? redirect?), and how the dialog differs when the game is
ALREADY in the collection (second-copy flow must create a new entry, not edit
the existing one).
## Login page (recon 2026-08-01, anonymous probe via Playwright)
- **Cloudflare Turnstile blocks headless browsers outright**: the headless
shell never gets past "Just a moment..." (`cf-turnstile-response` hidden
input, no form). A normal **headed** Chromium passed the check without
interaction. Hence `bggpipe upload` runs headed by default; `--headless`
exists but expect login to fail there. A first login in headed mode may
still need one human click on the challenge widget; the session then
persists via `storage_state.json` (gitignored).
- **BGG pages never reach Playwright's `networkidle`** — ad/analytics
requests poll forever. Navigate with `wait_until="domcontentloaded"` and
rely on element-level auto-waiting.
- Verified form selectors at `/login`: `#inputUsername` (name=`username`,
formcontrolname=`username`), `#inputPassword`, and a button with
accessible name **"Sign In"** (`type="button"` — Angular handles submit,
so click the button rather than pressing Enter and hoping for a form
submit). Labels "Username"/"Password" point at those ids. Cookie-consent
checkboxes (Essential, Performance Analytics, ...) render on the same
page but did not overlay the form in the probe.
- Logged-in detection heuristic (unverified): the header shows a "Sign In"
link only when logged out.
## Verified against the live site (2026-08-06, first real uploads)
The add flow works end to end; every failure on the way was in code the
earlier walkthrough had marked *verified*, and the parts marked
*unverified* were mostly right. Corrections:
- **Sign In is an `<a class="btn">` with no `href`.** It therefore has no
implicit `link` role: `get_by_role("link", name="Sign In")` matches
nothing in any state. The header also hydrates after
`domcontentloaded`, so for a moment neither Sign In nor Sign Out
exists — a check resting on one absence silently concludes "signed in"
and browses anonymously. Poll until one control or the other proves the
state; treat "neither, after 30s" as an error.
- **`get_by_label("Own")` also matches "Prev. Owned."** Use
`get_by_role("checkbox", name="Own", exact=True)`.
- **Version rows** are the `<li>`s carrying a thumbnail:
`li:has(.summary-item-thumbnail)`. Plain `listitem` also catches the
paging `<li>`s ("First", "Prev", "1", "…").
- **Paging is an AngularJS `<ul class="pagination">` of anchors**, not
buttons: `a[title="Next Page"]`, with the parent `<li>` gaining
`disabled` at the end. Paging is client-side over an already-loaded
list — no request per page.
- **Every paging control renders TWICE**: a desktop set and a mobile set
inside `<li class="visible-xs-*">`. A selector matches both, and
`.first` may be the hidden one — Playwright then waits for it to become
visible until it times out. Always click the first *visible* match.
First/Prev may be unclickable on a desktop viewport (mobile-only
variant). To return to page 1, click the visible numbered **"1"**
anchor: the sub-view's paging state SURVIVES closing and reopening it
(Angular keeps the scope), so a reopen lands wherever it was left, not
on page 1.
- **Match row text in Python, not with `has_text`.** Playwright's
`has_text` regex tests raw `textContent`, which carries the markup's
tabs and newlines; a whitespace-normalized capture will never equal it.
Normalize both sides yourself and click the row by index.
- **Row text is `<game name> (<version name>) (<year>)`**, and the game
name is localized (a Czech edition's row starts "Spící bohové"). Match
the version name inside its parentheses.
- **The API's version name is not always the picker's string.** BGG's
XML gives e.g. `English edition 2018-2` where the picker shows
`(English edition) (2018)`. Match the full name first, then retry with
a trailing year/printing qualifier stripped — and if that relaxed match
hits more than one row, refuse and add version-less (never guess).
- **An owned game's page has no "Add To" button**; it reads
"In Collections (Own…)". That is the update flow's entry point.
### The update flow (verified 2026-08-06)
Do **not** go through the game page's dialog for an existing entry — it
cannot say which copy it edits. The collection table can:
1. `/collection/user/<user>?objectid=<bgg_id>&own=1`.
2. The version cell carries its own collid:
`td.collection_version[onclick*="<collid>"]`. Clicking it opens an
inline editor (the cell shows *Editing* meanwhile).
3. The editor is a radio list whose **values are version ids**:
`form[id^="form_version"] input[type="radio"][value="<version_id>"]`.
Both the copy and the edition are therefore addressed exactly — no
name matching, no pagination.
4. Clicking the radio fires `CE_SaveData(cellid, collid, 'version')`
itself. **There is no Save button.** The save has landed when the
cell's text stops reading *Editing*.
This is strictly additive: it sets one field on one collid and cannot
create a duplicate entry.