slab-cli 0.20.0__tar.gz → 0.22.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {slab_cli-0.20.0 → slab_cli-0.22.0}/PKG-INFO +1 -1
  2. {slab_cli-0.20.0 → slab_cli-0.22.0}/pyproject.toml +1 -1
  3. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/browse.py +12 -3
  4. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/catalog.py +12 -8
  5. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/collection.py +58 -27
  6. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/custom_sets.py +9 -12
  7. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/pricing.py +17 -23
  8. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/display.py +143 -14
  9. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/help.py +4 -0
  10. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/main.py +4 -1
  11. slab_cli-0.22.0/src/slab_cli/nav.py +112 -0
  12. slab_cli-0.22.0/src/slab_cli/picker.py +178 -0
  13. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/prompts.py +8 -6
  14. slab_cli-0.20.0/src/slab_cli/picker.py +0 -78
  15. {slab_cli-0.20.0 → slab_cli-0.22.0}/README.md +0 -0
  16. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/__init__.py +0 -0
  17. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/banner.py +0 -0
  18. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/client.py +0 -0
  19. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/__init__.py +0 -0
  20. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/breaks.py +0 -0
  21. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/export.py +0 -0
  22. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/lots.py +0 -0
  23. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/registry.py +0 -0
  24. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/setup.py +0 -0
  25. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/update.py +0 -0
  26. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/config.py +0 -0
  27. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/context.py +0 -0
  28. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/flags.py +0 -0
  29. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/paging.py +0 -0
  30. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/platforms/__init__.py +0 -0
  31. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/platforms/base.py +0 -0
  32. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/platforms/posix.py +0 -0
  33. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/platforms/windows.py +0 -0
  34. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/sources.py +0 -0
  35. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/theme.py +0 -0
  36. {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: slab-cli
3
- Version: 0.20.0
3
+ Version: 0.22.0
4
4
  Summary: CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal.
5
5
  Author: dev_jeb
6
6
  Requires-Dist: slab-schemas>=0.35.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.20.0"
3
+ version = "0.22.0"
4
4
  description = "CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal."
5
5
  readme = "README.md"
6
6
  authors = [{name = "dev_jeb"}]
@@ -321,6 +321,7 @@ def browse(
321
321
  footer: str | None = None,
322
322
  start: int = 0,
323
323
  detail: Callable[[Any], "Page | None"] | None = None,
324
+ receipt: "Page | None" = None,
324
325
  ) -> Any | None:
325
326
  """Browse `rows` interactively. `columns` is the same `(header, kwargs)` spec `slab_table`
326
327
  takes; each row is `(item, cells)` with one markup string per column. `hint` names what
@@ -337,11 +338,17 @@ def browse(
337
338
 
338
339
  Quitting prints the top-level table (with `footer`) as a scrollback receipt. On a
339
340
  non-interactive stdout/stdin the static table is printed instead and None returned.
341
+
342
+ `receipt` swaps a different Page in for both of those static prints — for when the
343
+ interactive top level is a navigation aid (e.g. cards grouped by set) but the flat result
344
+ list is what scrollback and pipes should keep.
340
345
  """
341
346
  if not rows:
342
347
  return None
348
+ static = receipt or Page(columns, rows, title, note, hint, footer)
343
349
  if not _interactive_ok():
344
- print_table(columns, rows, title=title, note=note, footer=footer)
350
+ print_table(static.columns, static.rows,
351
+ title=static.title, note=static.note, footer=static.footer)
345
352
  return None
346
353
 
347
354
  root = Page(columns, rows, title, note, hint, footer, detail, sel=start)
@@ -374,6 +381,8 @@ def browse(
374
381
  if picked is not None:
375
382
  return picked
376
383
  # Quit: leave the classic table behind so the results live on in scrollback. It's the TOP
377
- # level's table however deep the user was — that's the list they asked for.
378
- print_table(root.columns, root.rows, title=root.title, note=root.note, footer=root.footer)
384
+ # level's table (or the `receipt` standing in for it) however deep the user was — that's
385
+ # the list they asked for.
386
+ print_table(static.columns, static.rows,
387
+ title=static.title, note=static.note, footer=static.footer)
379
388
  return None
@@ -41,13 +41,16 @@ def cmd_search(args: list[str]) -> None:
41
41
  Sort keys: year, card_number, numbered, subject, brand, set (prefix - for descending).
42
42
 
43
43
  Results are one row per CARD, however many parallels it has — a player with a 17-finish
44
- rainbow would otherwise bury his other cards under one of them. In a terminal that list is
45
- scrollable and drills down: Enter opens the card's printings (base + every parallel, with
46
- print run and odds), Enter again opens that printing's market view, Esc climbs back one level
47
- and q leaves. `--printings` flattens it back to one row per printing (finish, run, and odds
48
- on every row) — what you want when the finish IS the thing you're scanning for, and the form
49
- to pipe when a script wants every parallel. Piped output prints the plain table of whichever
50
- shape you asked for; the row shape doesn't change just because output is a terminal.
44
+ rainbow would otherwise bury his other cards under one of them. In a terminal the browse is
45
+ set-first: results are grouped into the sets they belong to (even a subset like Young Guns
46
+ spans several products), Enter opens that set's cards, Enter again the highlighted card's
47
+ printings (base + every parallel, with print run and odds), Enter once more that printing's
48
+ market view — Esc climbs back one level, q leaves. When every result is from one set the
49
+ set level is skipped and the browse opens straight on its cards. `--printings` flattens it
50
+ back to one row per printing (finish, run, and odds on every row) — what you want when the
51
+ finish IS the thing you're scanning for, and the form to pipe when a script wants every
52
+ parallel. Piped output prints the plain one-row-per-card (or per-printing) table; the row
53
+ shape doesn't change just because output is a terminal.
51
54
  """
52
55
  if not args:
53
56
  args = [ask_text("Search cards:")]
@@ -110,7 +113,8 @@ def cmd_rainbow(args: list[str]) -> None:
110
113
  if len(args) == 1 and _UUID_RE.match(args[0]):
111
114
  card_uuid = args[0]
112
115
  else:
113
- card = pick_card(" ".join(args) if args else None)
116
+ # Stop the picker at the card — this command's whole output IS the parallel step.
117
+ card = pick_card(" ".join(args) if args else None, printing=False)
114
118
  if card is None:
115
119
  return
116
120
  card_uuid = card.uuid
@@ -20,6 +20,7 @@ from slab_schemas.enums import AcquisitionType, CopyStatus, CostCategory, grade_
20
20
  from ..client import ApiError, SlabClient
21
21
  from ..context import ctx
22
22
  from ..flags import COLLECTION_SEARCH_FLAGS, parse_flags
23
+ from ..nav import BackStep, flow
23
24
  from ..paging import fetch_all_pages
24
25
  from ..picker import pick_card
25
26
  from ..display import (
@@ -75,12 +76,13 @@ class AddSession:
75
76
 
76
77
  def _search_and_select_card(session: AddSession) -> CardOut | None:
77
78
  """Find the exact card the user got, SUBJECT-FIRST: (1) search a player and pick them, (2) pick
78
- the set they're in, then (3) pick the exact printing — base, parallel, insert, auto. Step 2 is
79
- what stops a player who appears across many products from dumping into one undifferentiated list
80
- (the set name also rides each row when the list still spans sets). When a break is in play every
81
- search is scoped to that box (its release), so the set step auto-collapses and you only ever see
82
- cards that could have come from what you opened. Shares the picker with the rest of the CLI; the
83
- only add-specific bits are the next/first prompt and the box-scoped 'not found' message."""
79
+ the set they're in, (3) pick the card, then (4) the exact printing off its rainbow (base is a
80
+ single Enter). Step 2 is what stops a player who appears across many products from dumping into
81
+ one undifferentiated list (the set name also rides each row when the list still spans sets).
82
+ When a break is in play every search is scoped to that box (its release), so the set step
83
+ auto-collapses and you only ever see cards that could have come from what you opened. Shares
84
+ the picker with the rest of the CLI; the only add-specific bits are the next/first prompt and
85
+ the box-scoped 'not found' message."""
84
86
  prompt = "Search for next card (player):" if session.cards_added > 0 else "Search for a card (player):"
85
87
  not_found = (
86
88
  "No cards found in this box. Try a different player name."
@@ -245,31 +247,52 @@ def _submit_and_report(session: AddSession, card: CardOut, acq_cost: Decimal | N
245
247
  def _add_single_card(session: AddSession, card: CardOut | None = None) -> bool:
246
248
  """Run one add-card iteration. Returns True if the card was added, False if skipped/cancelled.
247
249
 
248
- When `card` is given (e.g. from a chase-set missing-card walk) the catalog search is skipped and
249
- that exact printing is used — you go straight to the acquisition details."""
250
+ The per-card sequence is a `nav.flow` — Esc at any detail step (cost, grades, serial, the
251
+ review confirm) goes back one step, and Esc past the first step skips this card. The picker
252
+ inside the first step is its own flow, so Esc also walks printing → card → set → player →
253
+ search text before falling out here.
254
+
255
+ When `card` is given (e.g. from a chase-set missing-card walk) the catalog search is skipped
256
+ and that exact printing is used — you go straight to the acquisition details."""
250
257
  # Decide how these cards were acquired (and the break/box) up front, so the search is scoped.
258
+ # Deliberately OUTSIDE the flow: it's batch-level state, and re-answering it per Esc would be
259
+ # worse than Esc simply skipping the card.
251
260
  _ensure_acquisition_context(session)
252
261
 
253
- if card is None:
254
- card = _search_and_select_card(session)
255
- if card is None:
256
- return False
262
+ def s_pick(st: dict) -> None:
263
+ picked = card if card is not None else _search_and_select_card(session)
264
+ if picked is None:
265
+ raise BackStep()
266
+ st["card"] = picked
267
+ console.print(f"\n [bold]{format_card_summary(picked)}[/bold]\n")
257
268
 
258
- console.print(f"\n [bold]{format_card_summary(card)}[/bold]\n")
269
+ def s_cost(st: dict) -> None:
270
+ st["acq_cost"] = _per_card_cost(session)
259
271
 
260
- acq_cost = _per_card_cost(session)
261
- break_uuid = session.break_uuid
262
- self_grade, grading_company, grade, cert_number = _collect_grade_details()
263
- serial = _prompt_serial(card)
272
+ def s_grades(st: dict) -> None:
273
+ st["grades"] = _collect_grade_details()
264
274
 
265
- _show_review(card, session, acq_cost, break_uuid, self_grade, grading_company, grade, serial)
275
+ def s_serial(st: dict) -> None:
276
+ st["serial"] = _prompt_serial(st["card"])
277
+
278
+ def s_review(st: dict) -> None:
279
+ self_grade, grading_company, grade, _cert = st["grades"]
280
+ _show_review(st["card"], session, st["acq_cost"], session.break_uuid,
281
+ self_grade, grading_company, grade, st["serial"])
282
+ st["confirmed"] = confirm("Add this card?", default=True)
283
+
284
+ try:
285
+ st = flow([s_pick, s_cost, s_grades, s_serial, s_review])
286
+ except BackStep:
287
+ return False
266
288
 
267
- if not confirm("Add this card?", default=True):
289
+ if not st["confirmed"]:
268
290
  console.print("[dim]Skipped.[/dim]\n")
269
291
  return False
270
292
 
271
- _submit_and_report(session, card, acq_cost, break_uuid, self_grade,
272
- grading_company, grade, cert_number, serial)
293
+ self_grade, grading_company, grade, cert_number = st["grades"]
294
+ _submit_and_report(session, st["card"], st["acq_cost"], session.break_uuid, self_grade,
295
+ grading_company, grade, cert_number, st["serial"])
273
296
  return True
274
297
 
275
298
 
@@ -277,11 +300,16 @@ def cmd_add(args: list[str]) -> None:
277
300
  """Add card(s) to your collection — interactive with batch support."""
278
301
  session = AddSession(client=ctx.client, collector=ctx.collector)
279
302
 
280
- while True:
281
- _add_single_card(session)
303
+ # Ctrl-C (or Esc at the "another?" prompt) ends the batch, not the receipt: fall through to
304
+ # the Done line so the user still sees what landed.
305
+ try:
306
+ while True:
307
+ _add_single_card(session)
282
308
 
283
- if not confirm("Add another card?", default=True):
284
- break
309
+ if not confirm("Add another card?", default=True):
310
+ break
311
+ except (KeyboardInterrupt, BackStep):
312
+ console.print()
285
313
 
286
314
  if session.cards_added > 0:
287
315
  console.print(f"[dim]Done — {session.cards_added} card(s) added.[/dim]")
@@ -623,8 +651,11 @@ def cmd_search(args: list[str]) -> None:
623
651
  Sort keys: acquired_date, acquisition_cost, numbered, year, card_number, brand, set,
624
652
  fmv, unrealized, roi (prefix - for descending).
625
653
 
626
- In a terminal the results are a scrollable list — Enter drills into the highlighted
627
- copy's market view, Esc climbs back. Piped output prints the plain table.
654
+ In a terminal the browse is set-first, same as `slab card search`: matches are grouped
655
+ into the sets they came from (with each set's copy count, cost basis, FMV, and gain/loss),
656
+ Enter opens that set's copies, Enter again the highlighted copy's market view — Esc climbs
657
+ back one level, q leaves. One-set results skip the set level. Piped output prints the plain
658
+ one-row-per-copy table.
628
659
  """
629
660
  if not args:
630
661
  total = ctx.client.search_collection(ctx.collector, CollectionSearchQuery(limit=1)).total
@@ -2,9 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from InquirerPy import inquirer
6
5
  from InquirerPy.base.control import Choice
7
6
 
7
+ from ..nav import inquirer
8
+
8
9
  from types import SimpleNamespace
9
10
 
10
11
  from slab_schemas.custom_sets import (
@@ -25,7 +26,7 @@ from ..display import (
25
26
  print_custom_set_detail,
26
27
  )
27
28
  from ..paging import fetch_all_pages
28
- from ..picker import search_and_pick_subject
29
+ from ..picker import pick_card_with_subject, search_and_pick_subject
29
30
  from ..prompts import (
30
31
  ask_int_optional,
31
32
  ask_text,
@@ -35,7 +36,6 @@ from ..prompts import (
35
36
  select_dynamic_match,
36
37
  select_entry_kind,
37
38
  select_match_mode,
38
- select_printing,
39
39
  select_visibility,
40
40
  )
41
41
 
@@ -93,17 +93,14 @@ def _pick_from_sets(sets: list[CustomSetOut], message: str) -> CustomSetOut | No
93
93
 
94
94
 
95
95
  def _search_and_select_card_for_set() -> tuple[str | None, str | None]:
96
- """Search the catalog, pick a player, pick a printing. Returns (card_uuid, subject_name) or
97
- (None, None). Uses the shared subject picker; the only chase-specific step is the summary print
98
- and the (uuid, subject) return shape the batch loop wants."""
99
- picked = search_and_pick_subject(None)
96
+ """Search the catalog, pick a player, narrow to a set (or all), pick the card, then the
97
+ exact printing off its rainbow — the shared picker flow (Esc steps back, Ctrl-C quits).
98
+ Returns (card_uuid, subject_name) or (None, None); the only chase-specific part is the
99
+ summary print and the (uuid, subject) return shape the batch loop wants."""
100
+ picked = pick_card_with_subject(None)
100
101
  if picked is None:
101
102
  return None, None
102
- cards, subject = picked
103
-
104
- card = select_printing(cards, subject)
105
- if card is None:
106
- return None, None
103
+ card, subject = picked
107
104
 
108
105
  console.print(f"\n [bold]{format_card_summary(card)}[/bold]\n")
109
106
  return card.uuid, subject
@@ -20,11 +20,10 @@ from ..display import (
20
20
  render_set_market,
21
21
  )
22
22
  from ..paging import fetch_all_pages
23
- from ..picker import pick_card, search_and_pick_subject
23
+ from ..picker import pick_card, pick_card_with_subject
24
24
  from ..prompts import (
25
25
  select_priced_card,
26
26
  select_set,
27
- select_set_filter,
28
27
  )
29
28
 
30
29
 
@@ -38,32 +37,25 @@ def _optional_collector() -> str | None:
38
37
 
39
38
 
40
39
  def _pick_card(args: list[str]) -> CardOut | None:
41
- """Price/comps selection: player → set (or all) → a card from the FMV-tagged list. Shares the
42
- subject-first core (`search_and_pick_subject`) with every other picker, then adds the two
43
- pricing-specific steps: the enriched search (each row carries its headline FMV + owned tag) and
44
- a set-narrowing step before the FMV list. Returns the chosen card, or None if the user backed
45
- out or nothing matched."""
46
- picked = search_and_pick_subject(
40
+ """Price/comps selection: player → set (or all) → a card from the FMV-tagged list → the
41
+ exact printing off its rainbow. The shared picker flow (Esc steps back, Ctrl-C quits); the
42
+ one pricing-specific part is the enriched card list (each slot row carries its headline FMV +
43
+ owned tag) in place of the plain card pick. Returns the chosen printing, or None if the user
44
+ backed out or nothing matched."""
45
+ picked = pick_card_with_subject(
47
46
  " ".join(args) if args else None,
48
47
  collector=_optional_collector(),
49
48
  include_market=True,
49
+ card_step=select_priced_card,
50
50
  )
51
- if picked is None:
52
- return None
53
- cards, subject = picked
54
-
55
- # Narrow to a set (auto-skips when the player only appears in one set).
56
- slug = select_set_filter(cards)
57
- if slug:
58
- cards = [c for c in cards if c.set_slug == slug]
59
-
60
- return select_priced_card(cards, subject, show_set=not slug)
51
+ return picked[0] if picked else None
61
52
 
62
53
 
63
54
  def cmd_price(args: list[str]) -> None:
64
- """Price a player's cards: pick the player, narrow to a set (or all), see every printing with
65
- its FMV (● = you own it), then drill into one for the full market view. Catalog-level — no
66
- ownership required, but owned copies are tagged when a collector is configured."""
55
+ """Price a player's cards: pick the player, narrow to a set (or all), see each card with its
56
+ headline FMV (● = you own it), pick one, then the exact printing off its rainbow for the full
57
+ market view. Catalog-level — no ownership required, but owned copies are tagged when a
58
+ collector is configured."""
67
59
  card = _pick_card(args)
68
60
  if card is None:
69
61
  return
@@ -348,9 +340,10 @@ def cmd_history(args: list[str]) -> None:
348
340
  slab card history bedard
349
341
  slab card history bedard --days 90 --daily
350
342
  slab card history mcdavid --monthly"""
351
- from InquirerPy import inquirer
352
343
  from InquirerPy.base.control import Choice
353
344
 
345
+ from ..nav import inquirer
346
+
354
347
  query, interval, days = _history_options(args)
355
348
  card = pick_card(" ".join(query) if query else None)
356
349
  if card is None:
@@ -389,9 +382,10 @@ def cmd_set_history(args: list[str]) -> None:
389
382
  Examples:
390
383
  slab set history allure
391
384
  slab set history "series 1" --days 180 --weekly"""
392
- from InquirerPy import inquirer
393
385
  from InquirerPy.base.control import Choice
394
386
 
387
+ from ..nav import inquirer
388
+
395
389
  query, interval, days = _history_options(args)
396
390
 
397
391
  sets = _fetch_all_sets()
@@ -125,6 +125,20 @@ PRINTING_COLUMNS = [
125
125
  ("Odds", {"style": "slab.dim"}),
126
126
  ]
127
127
 
128
+ # The set-first browse: search results bucketed into the sets they came from, one row per set.
129
+ # Even a subset query wants this level — Young Guns spans several products.
130
+ SET_GROUP_COLUMNS = [
131
+ ("Set", {"style": "slab.value"}),
132
+ ("Brand", {}),
133
+ ("Season", {"style": "slab.dim"}),
134
+ ("Cards", {"justify": "right", "style": "slab.accent"}),
135
+ ]
136
+
137
+ # Inside one set the Set column is a constant (it's the page title), so the in-set card list
138
+ # drops it. Derived from SLOT_COLUMNS so the two shapes can't drift.
139
+ _SET_COL = next(i for i, (header, _) in enumerate(SLOT_COLUMNS) if header == "Set")
140
+ IN_SET_SLOT_COLUMNS = [col for i, col in enumerate(SLOT_COLUMNS) if i != _SET_COL]
141
+
128
142
 
129
143
  def card_cells(c: CardOut) -> list[str]:
130
144
  """One catalog card as its CARD_COLUMNS cells."""
@@ -156,6 +170,25 @@ def printing_cells(c: CardOut) -> list[str]:
156
170
  ]
157
171
 
158
172
 
173
+ def in_set_slot_cells(c: CardOut) -> list[str]:
174
+ """`slot_cells` without the Set column — for the per-set card list, where Set is the title."""
175
+ return [cell for i, cell in enumerate(slot_cells(c)) if i != _SET_COL]
176
+
177
+
178
+ def group_by_set(cards: list[CardOut]) -> list[list[CardOut]]:
179
+ """Search results bucketed by set, in first-seen order — the top level of a card browse."""
180
+ groups: dict[str, list[CardOut]] = {}
181
+ for c in cards:
182
+ groups.setdefault(c.set_slug or c.set_name or "", []).append(c)
183
+ return list(groups.values())
184
+
185
+
186
+ def set_group_cells(cards: list[CardOut]) -> list[str]:
187
+ """One result-set bucket as its SET_GROUP_COLUMNS cells (any card carries the set fields)."""
188
+ rep = cards[0]
189
+ return [rep.set_name or "Unknown set", rep.brand or "", rep.season or "", str(len(cards))]
190
+
191
+
159
192
  def card_title(c: CardOut) -> str:
160
193
  """A single card named for a heading: '#86 Paul Coffey — 2021-22 Stature'."""
161
194
  subjects = ", ".join(s.name for s in c.subjects) or "—"
@@ -166,10 +199,14 @@ def browse_cards(result: CardSearchResult, *, detail=None, printings=None) -> Ca
166
199
  """Browse a card search. `detail` (a `CardOut -> None` renderer) is what Enter opens on a
167
200
  PRINTING; Esc climbs back. Static table on non-interactive output.
168
201
 
169
- With `printings` (a `CardOut -> list[CardOut]` loader, i.e. the parallels endpoint) the list is
170
- one row per card and Enter opens that card's printings as a nested list, whose Enter runs
171
- `detail` — so a search for a player reads as his cards, not as every finish of every card.
172
- Without it, every printing is its own row and Enter runs `detail` straight away."""
202
+ With `printings` (a `CardOut -> list[CardOut]` loader, i.e. the parallels endpoint) the browse
203
+ goes set-first: results grouped into the sets they came from, Enter opens that set's cards,
204
+ Enter again the card's printings, whose Enter runs `detail` — so a search for a player reads
205
+ as the sets he's in, then his cards in one, never as every finish of every card. When every
206
+ result is from a single set the set level would be a one-row picker, so it's skipped and the
207
+ browse opens on that set's cards. The static table (pipes, and the quit receipt) stays the
208
+ flat one-row-per-card list either way. Without `printings`, every printing is its own row and
209
+ Enter runs `detail` straight away."""
173
210
  if not result.items:
174
211
  console.print("[slab.dim]No cards found.[/]")
175
212
  return None
@@ -191,10 +228,29 @@ def browse_cards(result: CardSearchResult, *, detail=None, printings=None) -> Ca
191
228
  hint="market view", detail=detail,
192
229
  )
193
230
 
231
+ def cards_page(cards: list[CardOut]) -> Page:
232
+ rep = cards[0]
233
+ return Page(
234
+ IN_SET_SLOT_COLUMNS, [(c, in_set_slot_cells(c)) for c in cards],
235
+ title=rep.set_name or "Unknown set",
236
+ note=f"{len(cards)} card{'' if len(cards) == 1 else 's'}",
237
+ hint="printings", detail=rainbow,
238
+ )
239
+
240
+ flat = Page(SLOT_COLUMNS, [(c, slot_cells(c)) for c in result.items],
241
+ title="Cards", note=f"{result.total} total", footer=footer)
242
+
243
+ groups = group_by_set(result.items)
244
+ if len(groups) == 1:
245
+ page = cards_page(groups[0])
246
+ return browse(
247
+ page.columns, page.rows, title=page.title, note=page.note,
248
+ hint=page.hint, footer=footer, detail=rainbow, receipt=flat,
249
+ )
194
250
  return browse(
195
- SLOT_COLUMNS, [(c, slot_cells(c)) for c in result.items],
196
- title="Cards", note=f"{result.total} total", hint="printings",
197
- footer=footer, detail=rainbow,
251
+ SET_GROUP_COLUMNS, [(g, set_group_cells(g)) for g in groups],
252
+ title="Cards", note=f"{result.total} total · {len(groups)} sets",
253
+ hint="cards", footer=footer, detail=cards_page, receipt=flat,
198
254
  )
199
255
 
200
256
 
@@ -258,6 +314,21 @@ COLLECTION_COLUMNS = [
258
314
  ("Gain/Loss", {"justify": "right"}),
259
315
  ]
260
316
 
317
+ # The collection's set-first level: one row per set you hold copies from, with that set's share
318
+ # of the money — the same subject → set → card order the catalog browse and pickers use.
319
+ COLLECTION_SET_GROUP_COLUMNS = [
320
+ ("Set", {"style": "slab.value"}),
321
+ ("Copies", {"justify": "right", "style": "slab.accent"}),
322
+ ("Cost Basis", {"justify": "right"}),
323
+ ("FMV", {"justify": "right"}),
324
+ ("Gain/Loss", {"justify": "right"}),
325
+ ]
326
+
327
+ # Same derivation as the catalog's in-set columns: inside one set's page the Set column is the
328
+ # title, so it drops out of the rows.
329
+ _COLL_SET_COL = next(i for i, (header, _) in enumerate(COLLECTION_COLUMNS) if header == "Set")
330
+ IN_SET_COLLECTION_COLUMNS = [col for i, col in enumerate(COLLECTION_COLUMNS) if i != _COLL_SET_COL]
331
+
261
332
 
262
333
  def collection_cells(cp) -> list[str]:
263
334
  """One owned copy as its COLLECTION_COLUMNS cells."""
@@ -276,6 +347,42 @@ def collection_cells(cp) -> list[str]:
276
347
  return [card_num, subjects, set_name, finish, grade, basis, fmv, gain]
277
348
 
278
349
 
350
+ def in_set_collection_cells(cp) -> list[str]:
351
+ """`collection_cells` without the Set column — for the per-set copy list."""
352
+ return [cell for i, cell in enumerate(collection_cells(cp)) if i != _COLL_SET_COL]
353
+
354
+
355
+ def group_copies_by_set(copies) -> list[list]:
356
+ """Collection results bucketed by their card's set, in first-seen order. Copies with no
357
+ linked catalog card share one bucket (they have no set to claim)."""
358
+ groups: dict[str, list] = {}
359
+ for cp in copies:
360
+ card = cp.card
361
+ key = (card.set_slug or card.set_name or "") if card else ""
362
+ groups.setdefault(key, []).append(cp)
363
+ return list(groups.values())
364
+
365
+
366
+ def collection_set_group_cells(copies) -> list[str]:
367
+ """One set bucket of owned copies as its COLLECTION_SET_GROUP_COLUMNS cells. The money
368
+ columns sum whatever the copies report (a copy without a value contributes nothing, same as
369
+ the dashboard's totals)."""
370
+ rep = next((cp.card for cp in copies if cp.card), None)
371
+ count = sum(cp.quantity or 1 for cp in copies)
372
+ bases = [cp.cost_basis for cp in copies if cp.cost_basis is not None]
373
+ fmvs = [cp.market.fair_market_value for cp in copies
374
+ if cp.market and cp.market.fair_market_value is not None]
375
+ gains = [cp.market.unrealized_gain_loss for cp in copies
376
+ if cp.market and cp.market.unrealized_gain_loss is not None]
377
+ return [
378
+ (rep.set_name if rep and rep.set_name else "[slab.dim]No linked set[/]"),
379
+ str(count),
380
+ money(sum(bases)) if bases else "[slab.dim]—[/]",
381
+ f"[slab.foil]{money(sum(fmvs))}[/]" if fmvs else "[slab.dim]—[/]",
382
+ signed_money(sum(gains)) if gains else "[slab.dim]—[/]",
383
+ ]
384
+
385
+
279
386
  def print_collection_summary(result: CollectionResult) -> None:
280
387
  """The financial totals line under a collection search — from the server's summary block."""
281
388
  if not result.summary:
@@ -297,20 +404,42 @@ def print_collection_summary(result: CollectionResult) -> None:
297
404
 
298
405
 
299
406
  def browse_collection(result: CollectionResult, *, detail=None) -> None:
300
- """Browse a collection search. With `detail` (a copy -> None renderer), Enter drills into
301
- the copy's market view; Esc climbs back. Static table on non-interactive output."""
407
+ """Browse a collection search, set-first like the catalog browse: copies grouped into the
408
+ sets they came from (with each set's copy count and money rollup), Enter opens that set's
409
+ copies, Enter again the copy's market view via `detail` (a copy -> None renderer); Esc climbs
410
+ back a level. A single-set result skips the one-row set picker and opens on its copies. The
411
+ static table (pipes, and the quit receipt) stays the flat one-row-per-copy list."""
302
412
  if not result.items:
303
413
  console.print("[dim]No cards in your collection matching that search.[/dim]")
304
414
  if result.total == 0:
305
415
  console.print(" [dim]Use `slab collection add` to start cataloging cards.[/dim]")
306
416
  return
307
417
  matches = f"{result.total} match{'' if result.total == 1 else 'es'}"
418
+ footer = _shown_footer(len(result.items), result.total,
419
+ "narrow your search, or `slab export` for the full list.")
420
+
421
+ def copies_page(copies) -> Page:
422
+ rep = next((cp.card for cp in copies if cp.card), None)
423
+ return Page(
424
+ IN_SET_COLLECTION_COLUMNS, [(cp, in_set_collection_cells(cp)) for cp in copies],
425
+ title=(rep.set_name if rep and rep.set_name else "No linked set"),
426
+ note=f"{len(copies)} cop{'y' if len(copies) == 1 else 'ies'}",
427
+ hint="market view", detail=detail,
428
+ )
429
+
430
+ flat = Page(COLLECTION_COLUMNS, [(cp, collection_cells(cp)) for cp in result.items],
431
+ title="Collection", note=matches, footer=footer)
432
+
433
+ groups = group_copies_by_set(result.items)
434
+ if len(groups) == 1:
435
+ page = copies_page(groups[0])
436
+ browse(page.columns, page.rows, title=page.title, note=page.note,
437
+ hint=page.hint, footer=footer, detail=detail, receipt=flat)
438
+ return
308
439
  browse(
309
- COLLECTION_COLUMNS, [(cp, collection_cells(cp)) for cp in result.items],
310
- title="Collection", note=matches, hint="market view",
311
- footer=_shown_footer(len(result.items), result.total,
312
- "narrow your search, or `slab export` for the full list."),
313
- detail=detail,
440
+ COLLECTION_SET_GROUP_COLUMNS, [(g, collection_set_group_cells(g)) for g in groups],
441
+ title="Collection", note=f"{matches} · {len(groups)} sets",
442
+ hint="copies", footer=footer, detail=copies_page, receipt=flat,
314
443
  )
315
444
 
316
445
 
@@ -101,6 +101,10 @@ def render_root_help() -> None:
101
101
  console.print(heading(group.name, group.blurb))
102
102
  console.print(_group_table(group))
103
103
  console.print()
104
+ console.print(Text.from_markup(
105
+ " [slab.label]Keys[/] [slab.dim]In any interactive flow: "
106
+ "Esc goes back a step · Ctrl-C cancels the command[/]"
107
+ ))
104
108
  console.print(Text.from_markup(f" [slab.label]Config[/] [slab.dim]{_CONFIG}[/]"))
105
109
  # The installed version, right where people look for it. Whether it's the NEWEST version is the
106
110
  # update nudge's job (printed after any command from a cached daily check) — not restated here.
@@ -18,6 +18,7 @@ from .context import MissingCollector
18
18
  from .display import console
19
19
  from .flags import FlagError
20
20
  from .help import render_command_help, render_group_help, render_root_help
21
+ from .nav import BackStep
21
22
 
22
23
  _HELP_FLAGS = ("-h", "--help", "help")
23
24
  _VERSION_FLAGS = ("-V", "--version")
@@ -95,7 +96,9 @@ def _dispatch(args: list[str]) -> int:
95
96
  *any* path — success, cancel, or error — exactly once."""
96
97
  try:
97
98
  return _run(args)
98
- except KeyboardInterrupt:
99
+ except (KeyboardInterrupt, BackStep):
100
+ # The two standard leave-the-flow actions (nav.py): Ctrl-C anywhere, or Esc backing out
101
+ # past a flow's first step. Both end the command the same clean way.
99
102
  console.print("\n[dim]Cancelled.[/dim]")
100
103
  return 0
101
104
  except FlagError as e:
@@ -0,0 +1,112 @@
1
+ """The CLI's standard step navigation — ONE contract for every interactive flow.
2
+
3
+ Two actions work at EVERY prompt, the same keys the browse table already uses:
4
+
5
+ * **Esc — back one step.** Raises `BackStep`. Inside a `flow(...)` it returns to the previous
6
+ step that actually asked something (steps that auto-resolved — a lone match, a derived value —
7
+ are stepped over, so Esc never bounces off an invisible step). At the first step, or in a
8
+ prompt that isn't part of a flow, it bubbles up: the command cancels cleanly ("Cancelled.",
9
+ handled in `main._dispatch`) or an enclosing loop treats it as "skip this one".
10
+ * **Ctrl-C — quit.** InquirerPy raises KeyboardInterrupt, which every layer already treats as
11
+ "leave the whole command" (dispatch prints "Cancelled."; batch loops may catch it to print
12
+ their summary first). Nothing here intercepts it — quit must always win.
13
+
14
+ `q` is deliberately NOT a prompt-level key: in fuzzy filters and text inputs the letter q is
15
+ input. The browse table (browse.py) keeps its q-to-quit because its lists never type; Esc/Ctrl-C
16
+ mean the same thing there, so the two systems feel identical.
17
+
18
+ Mechanics: `inquirer` below is a drop-in proxy for `InquirerPy.inquirer` whose prompts execute
19
+ through the nav layer — import it from here instead of InquirerPy and every prompt gains the
20
+ contract with no per-call-site changes. `flow(steps)` is the step runner commands use to make
21
+ Esc rewind rather than cancel.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Any, Callable
27
+
28
+ from InquirerPy import inquirer as _inquirer
29
+
30
+
31
+ class BackStep(Exception):
32
+ """The user pressed Esc: go back one step. `flow` catches it to rewind; uncaught, it means
33
+ 'back out of this whole interaction' and dispatch turns it into a clean cancel."""
34
+
35
+
36
+ # Counts prompts actually SHOWN (not auto-resolved). `flow` reads it around each step to learn
37
+ # which steps interacted — that's what makes Esc skip over auto-resolved steps without every
38
+ # step having to declare itself.
39
+ _prompts_shown = 0
40
+
41
+
42
+ def prompt_shown() -> None:
43
+ """Record that a prompt was displayed. Called by the executor below; test doubles and
44
+ non-InquirerPy prompts (if any grow) call it to participate in flow()'s back-tracking."""
45
+ global _prompts_shown
46
+ _prompts_shown += 1
47
+
48
+
49
+ def _nav_execute(prompt) -> Any:
50
+ """Execute an InquirerPy prompt with the standard navigation attached: Esc raises BackStep,
51
+ Ctrl-C propagates as KeyboardInterrupt (InquirerPy's default)."""
52
+ prompt_shown()
53
+ backed = False
54
+
55
+ @prompt.register_kb("escape")
56
+ def _(event): # noqa: ANN001 - prompt_toolkit event
57
+ nonlocal backed
58
+ backed = True
59
+ event.app.exit(result=None)
60
+
61
+ result = prompt._nav_original_execute()
62
+ if backed:
63
+ raise BackStep()
64
+ return result
65
+
66
+
67
+ class _NavInquirer:
68
+ """`InquirerPy.inquirer`, with the nav contract attached to every prompt it builds."""
69
+
70
+ def __getattr__(self, name: str):
71
+ factory = getattr(_inquirer, name)
72
+
73
+ def build(*args, **kwargs):
74
+ prompt = factory(*args, **kwargs)
75
+ prompt._nav_original_execute = prompt.execute
76
+ prompt.execute = lambda: _nav_execute(prompt)
77
+ return prompt
78
+
79
+ return build
80
+
81
+
82
+ inquirer = _NavInquirer()
83
+
84
+
85
+ def flow(steps: list[Callable[[dict], None]], state: dict | None = None) -> dict:
86
+ """Run `steps` in order over a shared `state` dict, with Esc stepping backward.
87
+
88
+ Each step reads/writes `state` and prompts as it likes. When a step raises BackStep, the
89
+ flow resumes at the nearest EARLIER step that actually showed a prompt on its last run —
90
+ auto-resolved steps (nothing to re-answer) are skipped on the way back, then re-run on the
91
+ way forward so derived state stays fresh. BackStep at the first interactive step propagates:
92
+ the caller decides what backing out of the whole flow means (usually `return None`).
93
+ KeyboardInterrupt is never caught — quit always exits the flow. Returns `state`.
94
+ """
95
+ state = state if state is not None else {}
96
+ asked = [False] * len(steps)
97
+ i = 0
98
+ while i < len(steps):
99
+ before = _prompts_shown
100
+ try:
101
+ steps[i](state)
102
+ except BackStep:
103
+ j = i - 1
104
+ while j >= 0 and not asked[j]:
105
+ j -= 1
106
+ if j < 0:
107
+ raise
108
+ i = j
109
+ continue
110
+ asked[i] = _prompts_shown > before
111
+ i += 1
112
+ return state
@@ -0,0 +1,178 @@
1
+ """One subject-first card picker for the whole CLI.
2
+
3
+ Every command that resolves a human query to a specific catalog card (`card parallels`,
4
+ `card price`/`comps`, `card history`, `collection add`, `chase add`) does it the same way:
5
+ search a player, pick the player, narrow to a set (or all — auto-skipped when there's only
6
+ one), pick the CARD (one row per checklist slot), then pick the exact printing off that card's
7
+ rainbow (base is the default) — the same subject → set → card → parallel drill the browse table
8
+ gives search results. This module is that flow, once, built on `nav.flow` so the standard keys
9
+ work at every step: **Esc backs up one step** (printing → card → set → player → search text —
10
+ auto-resolved steps are stepped over) and **Ctrl-C quits the command**. Callers that need a
11
+ different card list (pricing's FMV-tagged rows) pass `card_step`; ones that need the subject
12
+ too use `pick_card_with_subject`.
13
+
14
+ The search is COLLAPSED and PAGED: one row per slot (`collapse_parallels=True`), fetched past
15
+ the server's 200-per-request cap to completion. Both halves matter — a prolific player has
16
+ thousands of printings (McDavid: 1,500+), so a single uncollapsed page silently dropped most of
17
+ his sets and made catalogued cards unfindable here while `card search` showed them fine. The
18
+ rainbow is fetched per picked card instead, exactly like the break-fill walk.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Callable
24
+
25
+ from slab_schemas.cards import CardOut, CardSearchQuery
26
+
27
+ from .context import ctx
28
+ from .nav import BackStep, flow
29
+ from .paging import fetch_all_pages
30
+ from .prompts import (
31
+ ask_text,
32
+ select_printing,
33
+ select_printing_for_slot,
34
+ select_set_filter,
35
+ select_subject,
36
+ )
37
+ from .theme import console
38
+
39
+ # Slots fetched per search, not printings — a safety bound, far above any real player's card count.
40
+ _SLOT_CAP = 5000
41
+
42
+
43
+ def _search_slots(q: str, **search_kwargs) -> list[CardOut]:
44
+ """Fetch EVERY slot matching a subject query (collapsed, paged — see module docstring)."""
45
+ slots, _ = fetch_all_pages(
46
+ lambda limit, offset: ctx.client.search_cards(CardSearchQuery(
47
+ subject=q, collapse_parallels=True, limit=limit, offset=offset, **search_kwargs
48
+ )),
49
+ cap=_SLOT_CAP,
50
+ )
51
+ return slots
52
+
53
+
54
+ def _subject_steps(
55
+ query: str | None, prompt: str, not_found: str, search_kwargs: dict,
56
+ ) -> list[Callable[[dict], None]]:
57
+ """The shared first three steps of every picker flow: ask/take the query, fetch all slots,
58
+ pick the player. Leaves `q`, `slots`, `subject`, and `cards` (the subject's slots) in the
59
+ flow state. An empty search raises BackStep — back to the query prompt to retype (or out of
60
+ the flow, when the query came from argv and there's nothing to return to)."""
61
+
62
+ def s_query(st: dict) -> None:
63
+ st["q"] = query if query else ask_text(prompt)
64
+
65
+ def s_fetch(st: dict) -> None:
66
+ st["slots"] = _search_slots(st["q"], **search_kwargs)
67
+ if not st["slots"]:
68
+ console.print(f"[yellow]{not_found}[/yellow]")
69
+ raise BackStep()
70
+
71
+ def s_subject(st: dict) -> None:
72
+ subject = select_subject(st["slots"])
73
+ if subject is None:
74
+ raise BackStep()
75
+ st["subject"] = subject
76
+ st["cards"] = [c for c in st["slots"] if any(s.name == subject for s in c.subjects)]
77
+
78
+ return [s_query, s_fetch, s_subject]
79
+
80
+
81
+ def search_and_pick_subject(
82
+ query: str | None,
83
+ *,
84
+ prompt: str = "Search for a card (player):",
85
+ not_found: str = "No cards found.",
86
+ **search_kwargs,
87
+ ) -> tuple[list[CardOut], str] | None:
88
+ """The subject-first core alone: resolve a query to ``(that subject's card slots, subject)``
89
+ — for callers that stop at the player (chase player-slots). Returns ``None`` when the user
90
+ backs out of the whole thing; Esc inside steps back (player pick → search text)."""
91
+ try:
92
+ st = flow(_subject_steps(query, prompt, not_found, search_kwargs))
93
+ except BackStep:
94
+ return None
95
+ return st["cards"], st["subject"]
96
+
97
+
98
+ def narrow_to_set(cards: list[CardOut]) -> tuple[list[CardOut], bool]:
99
+ """The shared set step: offer to narrow a cross-set card list to one set — "All sets" keeps
100
+ everything, and the prompt auto-skips when the list is already a single set. Returns the
101
+ (possibly narrowed) cards plus whether they still span sets: the ``show_set`` every card
102
+ picker takes."""
103
+ slug = select_set_filter(cards) # auto-returns "" (all) when the player is in only one set
104
+ if slug:
105
+ cards = [c for c in cards if c.set_slug == slug]
106
+ return cards, len({c.set_slug for c in cards}) > 1
107
+
108
+
109
+ def pick_printing_of(card: CardOut) -> CardOut | None:
110
+ """The final parallel step: fetch the picked card's whole rainbow and choose the exact
111
+ printing — base is the default, so the common case is a single Enter, and a slot with no
112
+ parallels auto-selects. Works from any printing (`get_parallels` resolves the slot's base)."""
113
+ return select_printing_for_slot(ctx.client.get_parallels(card.uuid))
114
+
115
+
116
+ def pick_card_with_subject(
117
+ query: str | None,
118
+ *,
119
+ prompt: str = "Search for a card (player):",
120
+ not_found: str = "No cards found.",
121
+ narrow_set: bool = True,
122
+ printing: bool = True,
123
+ card_step: Callable[..., CardOut | None] = select_printing,
124
+ **search_kwargs,
125
+ ) -> tuple[CardOut, str] | None:
126
+ """The full picker flow — player → set → card → parallel — returning ``(card, subject)``,
127
+ or ``None`` if nothing matched or the user backed all the way out. Esc at any step returns
128
+ to the previous one (auto-resolved steps are skipped).
129
+
130
+ The set step is the default because a player who appears across many products shouldn't be
131
+ dumped into one undifferentiated list (and it auto-skips when everything is one set anyway).
132
+ Pass ``narrow_set=False`` only when the search is already scoped to a single release (e.g.
133
+ filling a break). ``printing=False`` stops at the card — for callers that resolve the whole
134
+ rainbow themselves (`card parallels`). ``card_step`` swaps the card list for a variant with
135
+ the same ``(cards, subject, show_set=...)`` signature (pricing's FMV-tagged list)."""
136
+
137
+ def s_set(st: dict) -> None:
138
+ if narrow_set:
139
+ st["in_set"], st["show_set"] = narrow_to_set(st["cards"])
140
+ else:
141
+ st["in_set"] = st["cards"]
142
+ st["show_set"] = len({c.set_slug for c in st["cards"]}) > 1
143
+
144
+ def s_card(st: dict) -> None:
145
+ card = card_step(st["in_set"], st["subject"], show_set=st["show_set"])
146
+ if card is None:
147
+ raise BackStep()
148
+ st["card"] = card
149
+
150
+ def s_printing(st: dict) -> None:
151
+ picked = pick_printing_of(st["card"]) if printing else st["card"]
152
+ if picked is None:
153
+ raise BackStep()
154
+ st["picked"] = picked
155
+
156
+ steps = _subject_steps(query, prompt, not_found, search_kwargs) + [s_set, s_card, s_printing]
157
+ try:
158
+ st = flow(steps)
159
+ except BackStep:
160
+ return None
161
+ return st["picked"], st["subject"]
162
+
163
+
164
+ def pick_card(
165
+ query: str | None,
166
+ *,
167
+ prompt: str = "Search for a card (player):",
168
+ not_found: str = "No cards found.",
169
+ narrow_set: bool = True,
170
+ printing: bool = True,
171
+ **search_kwargs,
172
+ ) -> CardOut | None:
173
+ """The common case: `pick_card_with_subject`, returning just the chosen card."""
174
+ picked = pick_card_with_subject(
175
+ query, prompt=prompt, not_found=not_found,
176
+ narrow_set=narrow_set, printing=printing, **search_kwargs,
177
+ )
178
+ return picked[0] if picked else None
@@ -10,9 +10,10 @@ from datetime import date
10
10
  from decimal import Decimal, InvalidOperation
11
11
  from typing import Protocol
12
12
 
13
- from InquirerPy import inquirer
14
13
  from InquirerPy.base.control import Choice
15
14
 
15
+ from .nav import inquirer # the nav proxy: every prompt gains Esc=back / Ctrl-C=quit
16
+
16
17
  from slab_schemas.cards import CardOut, SetOut, SetSearchResult
17
18
  from slab_schemas.collection import (
18
19
  BreakOut,
@@ -156,10 +157,11 @@ def walk_prompt(card: CardOut, position: tuple[int, int]) -> tuple[str, str | No
156
157
 
157
158
 
158
159
  def select_printing(cards: list[CardOut], subject: str, show_set: bool = False) -> CardOut | None:
159
- """Subject-first step 2: pick the exact card for the chosen player — any printing (base,
160
- parallel, insert, auto). Auto-selects when there's only one. `show_set` prepends the set name
161
- to each row — used when the list spans multiple sets (e.g. the player wasn't narrowed to one
162
- set), so you can tell a 2023-24 Young Guns from a 2024-25 one at a glance."""
160
+ """Subject-first card step: pick WHICH CARD for the chosen player — one row per checklist
161
+ slot (the picker searches collapsed), its parallels chosen next via `pick_printing_of` /
162
+ `select_printing_for_slot`. Auto-selects when there's only one. `show_set` prepends the set
163
+ name to each row — used when the list spans multiple sets (e.g. the player wasn't narrowed
164
+ to one set), so you can tell a 2023-24 Young Guns from a 2024-25 one at a glance."""
163
165
  if not cards:
164
166
  return None
165
167
  if len(cards) == 1:
@@ -176,7 +178,7 @@ def select_printing(cards: list[CardOut], subject: str, show_set: bool = False)
176
178
 
177
179
  choices = [Choice(value=c, name=label(c)) for c in cards]
178
180
  return inquirer.fuzzy(
179
- message=f"Which card? — {subject} ({len(cards)} printings — type to filter):",
181
+ message=f"Which card? — {subject} ({len(cards)} cards — type to filter):",
180
182
  choices=choices,
181
183
  max_height="70%",
182
184
  ).execute()
@@ -1,78 +0,0 @@
1
- """One subject-first card picker for the whole CLI.
2
-
3
- Five commands (`card parallels`, `card price`/`comps`, `card history`, `collection add`,
4
- `chase add`) all resolve a human query to a specific catalog card the same way: search a player,
5
- pick the player, then pick the exact printing among that player's cards. This module is that flow,
6
- once. Callers that need a different *final* step (an FMV-tagged list, a set-narrowing step, a tuple
7
- return) build on `search_and_pick_subject`; the common case is `pick_card`.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- from slab_schemas.cards import CardOut, CardSearchQuery
13
-
14
- from .context import ctx
15
- from .prompts import ask_text, select_printing, select_set_filter, select_subject
16
- from .theme import console
17
-
18
-
19
- def search_and_pick_subject(
20
- query: str | None,
21
- *,
22
- prompt: str = "Search for a card (player):",
23
- not_found: str = "No cards found.",
24
- limit: int = 200,
25
- **search_kwargs,
26
- ) -> tuple[list[CardOut], str] | None:
27
- """The shared subject-first core: resolve a query to ``(that subject's cards, subject name)``.
28
-
29
- ``query`` is the search text; when falsy the user is prompted with ``prompt``. Extra
30
- ``search_kwargs`` (e.g. ``release=[...]`` to scope to a box, ``include_market=True``,
31
- ``collector=...``) pass straight through to ``CardSearchQuery``. Returns ``None`` if nothing
32
- matched (prints ``not_found``) or the user backed out of the player pick — so the caller only
33
- ever gets a non-empty card list plus the chosen subject.
34
- """
35
- q = query if query else ask_text(prompt)
36
- result = ctx.client.search_cards(CardSearchQuery(subject=q, limit=limit, **search_kwargs))
37
- if not result.items:
38
- console.print(f"[yellow]{not_found}[/yellow]")
39
- return None
40
-
41
- subject = select_subject(result.items)
42
- if subject is None:
43
- return None
44
-
45
- cards = [c for c in result.items if any(s.name == subject for s in c.subjects)]
46
- return cards, subject
47
-
48
-
49
- def pick_card(
50
- query: str | None,
51
- *,
52
- prompt: str = "Search for a card (player):",
53
- not_found: str = "No cards found.",
54
- limit: int = 200,
55
- narrow_set: bool = False,
56
- **search_kwargs,
57
- ) -> CardOut | None:
58
- """The common case: search a player, pick them, pick the exact printing. Returns the chosen
59
- card, or ``None`` if nothing matched or the user backed out at any step.
60
-
61
- With ``narrow_set=True`` an extra step lets you pick the set (or "all sets") after the player —
62
- the same player → set → card flow the pricing commands use — so a player who appears across many
63
- products isn't dumped into one undifferentiated list. The set name is shown on each row whenever
64
- the (post-narrowing) list still spans more than one set."""
65
- picked = search_and_pick_subject(
66
- query, prompt=prompt, not_found=not_found, limit=limit, **search_kwargs
67
- )
68
- if picked is None:
69
- return None
70
- cards, subject = picked
71
-
72
- if narrow_set:
73
- slug = select_set_filter(cards) # auto-returns "" (all) when the player is in only one set
74
- if slug:
75
- cards = [c for c in cards if c.set_slug == slug]
76
-
77
- show_set = len({c.set_slug for c in cards}) > 1
78
- return select_printing(cards, subject, show_set=show_set)
File without changes