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.
- {slab_cli-0.20.0 → slab_cli-0.22.0}/PKG-INFO +1 -1
- {slab_cli-0.20.0 → slab_cli-0.22.0}/pyproject.toml +1 -1
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/browse.py +12 -3
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/catalog.py +12 -8
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/collection.py +58 -27
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/custom_sets.py +9 -12
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/pricing.py +17 -23
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/display.py +143 -14
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/help.py +4 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/main.py +4 -1
- slab_cli-0.22.0/src/slab_cli/nav.py +112 -0
- slab_cli-0.22.0/src/slab_cli/picker.py +178 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/prompts.py +8 -6
- slab_cli-0.20.0/src/slab_cli/picker.py +0 -78
- {slab_cli-0.20.0 → slab_cli-0.22.0}/README.md +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/__init__.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/banner.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/client.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/__init__.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/breaks.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/export.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/lots.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/registry.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/setup.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/commands/update.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/config.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/context.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/flags.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/paging.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/platforms/__init__.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/platforms/base.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/platforms/posix.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/platforms/windows.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/sources.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/theme.py +0 -0
- {slab_cli-0.20.0 → slab_cli-0.22.0}/src/slab_cli/updates.py +0 -0
|
@@ -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,
|
|
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
|
|
378
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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,
|
|
79
|
-
what stops a player who appears across many products from dumping into
|
|
80
|
-
(the set name also rides each row when the list still spans sets).
|
|
81
|
-
search is scoped to that box (its release), so the set step
|
|
82
|
-
cards that could have come from what you opened. Shares
|
|
83
|
-
only add-specific bits are the next/first prompt and
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
269
|
+
def s_cost(st: dict) -> None:
|
|
270
|
+
st["acq_cost"] = _per_card_cost(session)
|
|
259
271
|
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
|
289
|
+
if not st["confirmed"]:
|
|
268
290
|
console.print("[dim]Skipped.[/dim]\n")
|
|
269
291
|
return False
|
|
270
292
|
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
|
627
|
-
|
|
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,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
42
|
-
|
|
43
|
-
pricing-specific
|
|
44
|
-
|
|
45
|
-
out or nothing matched."""
|
|
46
|
-
picked =
|
|
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
|
|
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
|
|
65
|
-
|
|
66
|
-
ownership required, but owned copies are tagged when a
|
|
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
|
|
170
|
-
|
|
171
|
-
`detail` — so a search for a player reads
|
|
172
|
-
|
|
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
|
-
|
|
196
|
-
title="Cards", note=f"{result.total} total
|
|
197
|
-
footer=footer, detail=
|
|
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
|
|
301
|
-
|
|
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
|
-
|
|
310
|
-
title="Collection", note=matches
|
|
311
|
-
footer=
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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)}
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|