slab-cli 0.13.0__tar.gz → 0.16.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 (34) hide show
  1. {slab_cli-0.13.0 → slab_cli-0.16.0}/PKG-INFO +2 -2
  2. {slab_cli-0.13.0 → slab_cli-0.16.0}/pyproject.toml +3 -2
  3. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/browse.py +83 -28
  4. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/client.py +12 -0
  5. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/catalog.py +20 -3
  6. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/custom_sets.py +123 -24
  7. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/registry.py +4 -3
  8. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/display.py +77 -11
  9. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/flags.py +6 -1
  10. {slab_cli-0.13.0 → slab_cli-0.16.0}/README.md +0 -0
  11. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/__init__.py +0 -0
  12. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/banner.py +0 -0
  13. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/__init__.py +0 -0
  14. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/breaks.py +0 -0
  15. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/collection.py +0 -0
  16. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/export.py +0 -0
  17. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/lots.py +0 -0
  18. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/pricing.py +0 -0
  19. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/setup.py +0 -0
  20. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/update.py +0 -0
  21. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/config.py +0 -0
  22. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/context.py +0 -0
  23. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/help.py +0 -0
  24. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/main.py +0 -0
  25. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/paging.py +0 -0
  26. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/picker.py +0 -0
  27. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/platforms/__init__.py +0 -0
  28. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/platforms/base.py +0 -0
  29. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/platforms/posix.py +0 -0
  30. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/platforms/windows.py +0 -0
  31. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/prompts.py +0 -0
  32. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/sources.py +0 -0
  33. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/theme.py +0 -0
  34. {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/updates.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: slab-cli
3
- Version: 0.13.0
3
+ Version: 0.16.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
- Requires-Dist: slab-schemas>=0.1.0
6
+ Requires-Dist: slab-schemas>=0.31.0
7
7
  Requires-Dist: httpx>=0.27
8
8
  Requires-Dist: rich>=13.0
9
9
  Requires-Dist: inquirerpy>=0.3
@@ -1,12 +1,13 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.13.0"
3
+ version = "0.16.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"}]
7
7
  requires-python = ">=3.13"
8
8
  dependencies = [
9
- "slab-schemas>=0.1.0",
9
+ # 0.31: CardSearchQuery.collapse_parallels — `card search` sends it on every query.
10
+ "slab-schemas>=0.31.0",
10
11
  "httpx>=0.27",
11
12
  "rich>=13.0",
12
13
  "InquirerPy>=0.3",
@@ -4,11 +4,17 @@
4
4
  keyboard-driven viewport: ↑/↓ (or j/k) move the highlight, PgUp/PgDn jump, `/` type-to-filters
5
5
  across every column, and q leaves — printing the plain table on the way out so the data
6
6
  survives in scrollback. Enter drills down: with a `detail` callback the highlighted row's
7
- detail view REPLACES the list as a scrollable page (Esc/q climbs back up to the list, same row
7
+ detail view REPLACES the list as a scrollable page (Esc climbs back up to the list, same row
8
8
  highlighted); without one, Enter simply returns the row's item to the caller. Anywhere output
9
9
  isn't an interactive terminal (a pipe, CI, redirection) it degrades to the static table, so
10
10
  `slab ... | grep` keeps working unchanged.
11
11
 
12
+ **A drill-down may be another list.** When `detail` returns a `Page` instead of printing, that
13
+ page is pushed as its own list screen with its own columns and its own `detail` — so a search can
14
+ show one row per card, its parallels one level down, and that printing's market view one level
15
+ below THAT, without any screen knowing how deep it sits. Esc pops exactly one level; q leaves the
16
+ whole stack at once (and prints the top-level table as the receipt).
17
+
12
18
  Reading keys is the one OS-specific part, and this module doesn't do it: `platforms.current()`
13
19
  hands back a backend whose `key_source()` yields a `read()` returning platform-neutral key NAMES
14
20
  ('up', 'enter', …). Everything below is written against those names, so it behaves identically on
@@ -21,6 +27,7 @@ the same spec drives the interactive view, the static fallback, and the exit rec
21
27
  from __future__ import annotations
22
28
 
23
29
  import sys
30
+ from dataclasses import dataclass, field
24
31
  from typing import Any, Callable, Sequence
25
32
 
26
33
  from rich.console import Group
@@ -33,6 +40,22 @@ from .theme import console, heading, slab_table
33
40
  # One row: the domain object Enter should act on, plus its cell markup strings (one per column).
34
41
  Row = tuple[Any, Sequence[str]]
35
42
 
43
+
44
+ @dataclass
45
+ class Page:
46
+ """A list screen. Return one from a `detail` callback to drill from a list INTO another list
47
+ (instead of into a printed page) — same columns/rows/hint contract the root browse takes, so a
48
+ level is described the same way whether it's the first screen or the third."""
49
+
50
+ columns: list[tuple[str, dict]]
51
+ rows: list[Row]
52
+ title: str
53
+ note: str | None = None
54
+ hint: str = "open"
55
+ footer: str | None = None
56
+ detail: Callable[[Any], "Page | None"] | None = None
57
+ sel: int = field(default=0, repr=False) # where this level's highlight sits, kept across pops
58
+
36
59
  # The highlight bar — a slate a step brighter than the card border, so the selected row reads as
37
60
  # lit without fighting the cells' own foreground colors (markup styles set color only, never bg).
38
61
  _SELECTED_BG = "on #223052"
@@ -149,9 +172,13 @@ def _run_list(
149
172
  note: str | None,
150
173
  hint: str,
151
174
  start: int,
152
- ) -> tuple[Any | None, int]:
153
- """The list screen. Returns (chosen item, its index in `rows`) on Enter, or (None, last
154
- highlighted index) on quit — the index lets a drill-down return to the same row."""
175
+ ) -> tuple[Any | None, int, str]:
176
+ """The list screen. Returns (chosen item, its index in `rows`, action).
177
+
178
+ The action is "select" (Enter — item is set), "back" (Esc: up one level) or "quit" (q: leave
179
+ browsing entirely). The index is where the highlight ended up, so a drill-down returns to the
180
+ same row; Esc and q are different answers because with nested lists only one of them means
181
+ "I'm done", and collapsing them would strand a user three levels deep."""
155
182
  plains = [row_plain(cells) for _item, cells in rows]
156
183
  query, typing = "", False
157
184
  sel = min(max(start, 0), len(rows) - 1)
@@ -198,14 +225,14 @@ def _run_list(
198
225
  if typing:
199
226
  typing = False # keep the filter, back to moving
200
227
  elif view:
201
- return view[sel][0], row_index(view[sel])
228
+ return view[sel][0], row_index(view[sel]), "select"
202
229
  elif key == "esc":
203
230
  if typing or query:
204
231
  typing, query = False, ""
205
232
  else:
206
- return None, sel
233
+ return None, (row_index(view[sel]) if view else 0), "back"
207
234
  elif key == "quit" or (not typing and key == "q"):
208
- return None, (row_index(view[sel]) if view else 0)
235
+ return None, (row_index(view[sel]) if view else 0), "quit"
209
236
  elif typing:
210
237
  if key == "backspace":
211
238
  if query:
@@ -260,11 +287,9 @@ def _run_pager(read: KeyReader, lines: list[Text], *, back_hint: str) -> None:
260
287
  return
261
288
 
262
289
 
263
- def _capture_lines(render: Callable[[], None]) -> list[Text]:
264
- """Run a print-to-console renderer and capture its output as styled lines for the pager."""
265
- with console.capture() as cap:
266
- render()
267
- lines = [Text.from_ansi(line) for line in cap.get().splitlines()]
290
+ def _lines_from(captured: str) -> list[Text]:
291
+ """Captured console output as trimmed styled lines — what the pager scrolls."""
292
+ lines = [Text.from_ansi(line) for line in captured.splitlines()]
268
293
  while lines and not lines[-1].plain.strip():
269
294
  lines.pop()
270
295
  while lines and not lines[0].plain.strip():
@@ -272,6 +297,20 @@ def _capture_lines(render: Callable[[], None]) -> list[Text]:
272
297
  return lines or [Text.from_markup("[slab.dim]Nothing to show.[/]")]
273
298
 
274
299
 
300
+ def _open(detail: Callable[[Any], "Page | None"], item: Any) -> tuple["Page | None", list[Text]]:
301
+ """Run a drill-down and report which kind it was: `(page, lines)`.
302
+
303
+ A detail callback either returns a Page (another list) or prints its view and returns None, and
304
+ it can only say which by running — so the console is captured either way and the lines are kept
305
+ only for the printing kind. An empty Page falls back to its captured lines too, so a drill-down
306
+ that found nothing shows a page saying so rather than a dead list screen."""
307
+ with console.capture() as cap:
308
+ page = detail(item)
309
+ if page is not None and page.rows:
310
+ return page, []
311
+ return None, _lines_from(cap.get())
312
+
313
+
275
314
  def browse(
276
315
  columns: list[tuple[str, dict]],
277
316
  rows: list[Row],
@@ -281,19 +320,23 @@ def browse(
281
320
  hint: str = "open",
282
321
  footer: str | None = None,
283
322
  start: int = 0,
284
- detail: Callable[[Any], None] | None = None,
323
+ detail: Callable[[Any], "Page | None"] | None = None,
285
324
  ) -> Any | None:
286
325
  """Browse `rows` interactively. `columns` is the same `(header, kwargs)` spec `slab_table`
287
326
  takes; each row is `(item, cells)` with one markup string per column. `hint` names what
288
327
  Enter does in the footer (e.g. "market view"); `start` pre-selects a row.
289
328
 
290
- With a `detail` callback, Enter drills down: `detail(item)` runs (it may fetch and must
291
- print to the console), its output replaces the list as a scrollable page, and leaving the
292
- page returns to the list on the same row — the loop lives here, and browse returns None
293
- when the user finally quits. Without `detail`, Enter returns the chosen item directly.
329
+ With a `detail` callback, Enter drills down and `detail(item)` decides into WHAT:
294
330
 
295
- Quitting from the list prints the plain table (with `footer`) as a scrollback receipt. On
296
- a non-interactive stdout/stdin the static table is printed instead and None returned.
331
+ * it prints to the console and returns None — the output replaces the list as a scrollable
332
+ page (the leaf view: a market report, a break's card);
333
+ * it returns a `Page` — that page becomes another list screen, with its own `detail`.
334
+
335
+ Either way Esc climbs back one level, landing on the row it left from, and q leaves the whole
336
+ stack. Without a `detail`, Enter returns the chosen item to the caller instead.
337
+
338
+ Quitting prints the top-level table (with `footer`) as a scrollback receipt. On a
339
+ non-interactive stdout/stdin the static table is printed instead and None returned.
297
340
  """
298
341
  if not rows:
299
342
  return None
@@ -301,24 +344,36 @@ def browse(
301
344
  print_table(columns, rows, title=title, note=note, footer=footer)
302
345
  return None
303
346
 
347
+ root = Page(columns, rows, title, note, hint, footer, detail, sel=start)
304
348
  picked: Any | None = None
305
- sel = start
306
349
  try:
307
350
  with current_platform().key_source() as read:
308
- while True:
309
- item, sel = _run_list(read, columns, rows, title=title, note=note,
310
- hint=hint, start=sel)
311
- if item is None:
351
+ stack = [root]
352
+ while stack:
353
+ page = stack[-1]
354
+ item, page.sel, action = _run_list(
355
+ read, page.columns, page.rows,
356
+ title=page.title, note=page.note, hint=page.hint, start=page.sel,
357
+ )
358
+ if action == "quit":
312
359
  break
313
- if detail is None:
360
+ if action == "back":
361
+ stack.pop()
362
+ continue
363
+ if page.detail is None:
314
364
  picked = item
315
365
  break
316
- _run_pager(read, _capture_lines(lambda: detail(item)), back_hint="back to list")
366
+ drilled, lines = _open(page.detail, item)
367
+ if drilled is not None and drilled.rows:
368
+ stack.append(drilled)
369
+ else:
370
+ _run_pager(read, lines, back_hint="back to list")
317
371
  except KeyboardInterrupt:
318
372
  pass
319
373
 
320
374
  if picked is not None:
321
375
  return picked
322
- # Quit: leave the classic table behind so the results live on in scrollback.
323
- print_table(columns, rows, title=title, note=note, footer=footer)
376
+ # 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)
324
379
  return None
@@ -40,6 +40,7 @@ from slab_schemas.collection import (
40
40
  from slab_schemas.custom_sets import (
41
41
  CustomSetCardAdd,
42
42
  CustomSetCardOut,
43
+ CustomSetCardUpdate,
43
44
  CustomSetCreate,
44
45
  CustomSetDetail,
45
46
  CustomSetOut,
@@ -316,6 +317,17 @@ class SlabClient:
316
317
  )
317
318
  return CustomSetCardOut.model_validate(resp.json())
318
319
 
320
+ def update_set_card(
321
+ self, collector_uuid: str, set_uuid: str, entry_uuid: str, payload: CustomSetCardUpdate
322
+ ) -> CustomSetCardOut:
323
+ # Sparse by fields-set (not exclude_none): explicitly passing None CLEARS a qualifier.
324
+ resp = self._request(
325
+ "PATCH",
326
+ f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/cards/{entry_uuid}",
327
+ json=payload.model_dump(mode="json", exclude_unset=True),
328
+ )
329
+ return CustomSetCardOut.model_validate(resp.json())
330
+
319
331
  def delete_set_card(self, collector_uuid: str, set_uuid: str, entry_uuid: str) -> None:
320
332
  self._request("DELETE", f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/cards/{entry_uuid}")
321
333
 
@@ -34,25 +34,42 @@ def cmd_search(args: list[str]) -> None:
34
34
  slab card search --brand "Upper Deck" --run-min 1 --run-max 99 # print-run range
35
35
  slab card search McDavid --auto --parallel --sort -year # stack modifiers, newest first
36
36
  slab card search --subset "Young Guns" --rookie --limit 50
37
+ slab card search Coffey --printings # every finish as its own row
37
38
 
38
39
  Every flag combines with AND; repeat an any-of flag (--team, --brand, --finish…) to widen it.
39
40
 
40
41
  Sort keys: year, card_number, numbered, subject, brand, set (prefix - for descending).
41
42
 
42
- In a terminal the results are a scrollable list — Enter drills into the highlighted
43
- card's market view, Esc climbs back. Piped output prints the plain table.
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
51
  """
45
52
  if not args:
46
53
  args = [ask_text("Search cards:")]
47
54
 
48
55
  values, q = parse_flags(args, CARD_SEARCH_FLAGS)
49
56
  values.setdefault("limit", 25)
57
+ values.setdefault("collapse_parallels", True)
50
58
  result = ctx.client.search_cards(CardSearchQuery(q=q, **values))
51
59
 
52
60
  def market_view(c) -> None:
53
61
  render_card_market(ctx.client.get_card_market(c.uuid))
54
62
 
55
- browse_cards(result, detail=market_view)
63
+ # The rainbow is fetched on drill-in, not up front: one call per card the user actually opens,
64
+ # and it returns the WHOLE slot even when the search matched a single finish.
65
+ def rainbow(c) -> list:
66
+ return ctx.client.get_parallels(c.uuid)
67
+
68
+ browse_cards(
69
+ result,
70
+ detail=market_view,
71
+ printings=rainbow if values["collapse_parallels"] else None,
72
+ )
56
73
 
57
74
 
58
75
  cmd_search.flags = CARD_SEARCH_FLAGS
@@ -236,7 +236,8 @@ def _build_dynamic_filter() -> dict | None:
236
236
  # ---------------------------------------------------------------------------
237
237
 
238
238
  def cmd_chase_add(args: list[str]) -> None:
239
- """Add cards to a curated custom set — batch mode like `slab collection add`."""
239
+ """Add entries to a curated custom set — specific cards or player slots, batch mode like
240
+ `slab collection add`."""
240
241
  collector = ctx.collector
241
242
  client = ctx.client
242
243
 
@@ -388,7 +389,7 @@ def cmd_chase_view(args: list[str]) -> None:
388
389
 
389
390
 
390
391
  def cmd_chase_edit(args: list[str]) -> None:
391
- """Edit a custom set's name, description, or visibility."""
392
+ """Edit a custom set's name, description, visibility, or (dynamic) match granularity."""
392
393
  collector = ctx.collector
393
394
  client = ctx.client
394
395
 
@@ -404,7 +405,10 @@ def cmd_chase_edit(args: list[str]) -> None:
404
405
 
405
406
  console.print(f"\n Current name: {cs.name}")
406
407
  console.print(f" Current description: {cs.description or '—'}")
407
- console.print(f" Current visibility: {cs.visibility}\n")
408
+ console.print(f" Current visibility: {cs.visibility}")
409
+ if cs.set_type == "dynamic":
410
+ console.print(f" Current match granularity: {detail.dynamic_match}")
411
+ console.print()
408
412
 
409
413
  from slab_schemas.custom_sets import CustomSetUpdate
410
414
 
@@ -413,11 +417,20 @@ def cmd_chase_edit(args: list[str]) -> None:
413
417
  vis = None
414
418
  if confirm("Change visibility?", default=False):
415
419
  vis = select_visibility()
420
+ new_match = None
421
+ if cs.set_type == "dynamic" and confirm(
422
+ "Change what counts as having an entry? (per printing / per card / per player)",
423
+ default=False,
424
+ ):
425
+ new_match = select_dynamic_match(default=detail.dynamic_match or "any_printing")
426
+ if new_match == detail.dynamic_match:
427
+ new_match = None
416
428
 
417
429
  update = CustomSetUpdate(
418
430
  name=name,
419
431
  description=description,
420
432
  visibility=Visibility(vis) if vis else None,
433
+ dynamic_match=DynamicMatch(new_match) if new_match else None,
421
434
  )
422
435
 
423
436
  # Only send if something changed
@@ -427,6 +440,11 @@ def cmd_chase_edit(args: list[str]) -> None:
427
440
 
428
441
  updated = client.update_custom_set(collector, cs.uuid, update)
429
442
  console.print(f"[green]Updated![/green] {updated.name}")
443
+ if new_match:
444
+ console.print(
445
+ f" [dim]Membership redefined — the set now tracks one entry per "
446
+ f"{'player' if new_match == 'any_card' else 'card slot' if new_match == 'any_printing' else 'printing'}.[/dim]"
447
+ )
430
448
 
431
449
 
432
450
  # ---------------------------------------------------------------------------
@@ -521,33 +539,114 @@ def cmd_chase_remove(args: list[str]) -> None:
521
539
  console.print("[yellow]Can only remove cards from curated sets.[/yellow]")
522
540
  return
523
541
 
524
- detail = _fetch_full_detail(client, cs.uuid, collector)
525
- if not detail.cards:
526
- console.print("[dim]This set has no cards.[/dim]")
527
- return
542
+ entry = _pick_entry(client, collector, cs.uuid, "Remove which entry?")
543
+ if entry and confirm(f"Remove this entry? ({entry.uuid[:8]})", default=False):
544
+ client.delete_set_card(collector, cs.uuid, entry.uuid)
545
+ console.print("[green]Removed.[/green]")
528
546
 
529
- choices = []
530
- for entry in detail.cards:
531
- card = entry.card
532
- if card:
533
- subjects = ", ".join(s.name for s in card.subjects)
534
- label = f"{card.card_number} {subjects} — {entry.match_mode}"
535
- if entry.serial_number is not None:
536
- label += f" #{entry.serial_number}"
537
- else:
538
- label = f"{entry.subject or '—'} — any card"
539
- owned = " ✓" if entry.owned else ""
540
- choices.append(Choice(value=entry, name=f"{label}{owned}"))
541
547
 
542
- entry = inquirer.select(
543
- message=f"Remove which card? ({len(detail.cards)} in set):",
548
+ def _entry_label(entry) -> str:
549
+ """One line describing an entry — the card and its mode, or the player slot + qualifiers."""
550
+ card = entry.card
551
+ if card:
552
+ subjects = ", ".join(s.name for s in card.subjects)
553
+ label = f"{card.card_number} {subjects} — {entry.match_mode}"
554
+ if entry.serial_number is not None:
555
+ label += f" #{entry.serial_number}"
556
+ return label
557
+ quals = " · ".join(str(q) for q in (
558
+ f"{entry.subject_year}-{str(entry.subject_year + 1)[-2:]}" if entry.subject_year else None,
559
+ entry.subject_team, entry.subject_attribute) if q)
560
+ return f"{entry.subject or '—'} — any card" + (f" · {quals}" if quals else "")
561
+
562
+
563
+ def _pick_entry(client: SlabClient, collector: str, set_uuid: str, message: str):
564
+ """Fetch the full entry list and pick one (None if empty or backed out)."""
565
+ detail = _fetch_full_detail(client, set_uuid, collector)
566
+ if not detail.cards:
567
+ console.print("[dim]This set has no entries.[/dim]")
568
+ return None
569
+ choices = [
570
+ Choice(value=e, name=f"{_entry_label(e)}{' ✓' if e.owned else ''}")
571
+ for e in detail.cards
572
+ ]
573
+ return inquirer.select(
574
+ message=f"{message} ({len(detail.cards)} in set):",
544
575
  choices=choices,
545
576
  max_height="70%",
546
577
  ).execute()
547
578
 
548
- if entry and confirm(f"Remove this card entry? ({entry.uuid[:8]})", default=False):
549
- client.delete_set_card(collector, cs.uuid, entry.uuid)
550
- console.print("[green]Removed.[/green]")
579
+
580
+ # ---------------------------------------------------------------------------
581
+ # slab chase tune — retune one entry (match mode / player-slot qualifiers)
582
+ # ---------------------------------------------------------------------------
583
+
584
+ def cmd_chase_tune(args: list[str]) -> None:
585
+ """Retune one entry of a curated set: a card entry's match mode, or a player slot's
586
+ season/team/attribute qualifiers. An entry's KIND (card vs player) is fixed — switch by
587
+ removing and re-adding."""
588
+ collector = ctx.collector
589
+ client = ctx.client
590
+
591
+ cs = _select_custom_set(client, collector, message="Which set to tune?")
592
+ if cs is None:
593
+ return
594
+ if cs.set_type != "curated":
595
+ console.print("[yellow]Dynamic sets have one set-wide granularity — use `slab chase edit`.[/yellow]")
596
+ return
597
+ detail = client.get_custom_set(cs.uuid, collector, limit=1)
598
+ if detail.creator_uuid != collector:
599
+ console.print("[yellow]You can only tune sets you created.[/yellow]")
600
+ return
601
+
602
+ entry = _pick_entry(client, collector, cs.uuid, "Tune which entry?")
603
+ if entry is None:
604
+ return
605
+
606
+ from slab_schemas.custom_sets import CustomSetCardUpdate
607
+
608
+ changes: dict = {}
609
+ if entry.card:
610
+ console.print(f"\n {_entry_label(entry)}")
611
+ new_mode = select_match_mode()
612
+ if new_mode != entry.match_mode:
613
+ changes["match_mode"] = MatchMode(new_mode)
614
+ if new_mode == "exact_serial":
615
+ serial = ask_int_optional("Which serial number?")
616
+ if serial is None:
617
+ console.print("[yellow]Serial number is required for exact_serial.[/yellow]")
618
+ return
619
+ if serial != entry.serial_number:
620
+ changes["serial_number"] = serial
621
+ else:
622
+ console.print(f"\n {_entry_label(entry)}")
623
+ console.print(" [dim]Retune the qualifiers — Enter keeps the current value, '-' clears it.[/dim]")
624
+ for field, label, cast in (
625
+ ("subject_year", f"Season (current: {entry.subject_year or 'any'}):", int),
626
+ ("subject_team", f"Team (current: {entry.subject_team or 'any'}):", str),
627
+ ("subject_attribute", f"Attribute (current: {entry.subject_attribute or 'any'}):", str),
628
+ ):
629
+ raw = ask_text_optional(label)
630
+ if not raw:
631
+ continue
632
+ if raw.strip() == "-":
633
+ if getattr(entry, field) is not None:
634
+ changes[field] = None
635
+ continue
636
+ try:
637
+ value = cast(raw.strip())
638
+ except ValueError:
639
+ console.print(f"[yellow] Skipped — not a valid value for {field}.[/yellow]")
640
+ continue
641
+ if value != getattr(entry, field):
642
+ changes[field] = value
643
+
644
+ if not changes:
645
+ console.print("[dim]No changes.[/dim]")
646
+ return
647
+
648
+ updated = client.update_set_card(collector, cs.uuid, entry.uuid, CustomSetCardUpdate(**changes))
649
+ console.print(f"[green]Tuned![/green] {_entry_label(updated)}")
551
650
 
552
651
 
553
652
  # ---------------------------------------------------------------------------
@@ -94,13 +94,14 @@ GROUPS: list[Group] = [
94
94
  ]),
95
95
  Group("chase", "custom sets & set-completion", [
96
96
  Command("create", "Create a custom set (curated or dynamic)", custom_sets.cmd_chase_create),
97
- Command("add", "Add cards to a curated set", custom_sets.cmd_chase_add),
97
+ Command("add", "Add cards or player slots to a curated set", custom_sets.cmd_chase_add),
98
98
  Command("view", "View a set with completion + add missing", custom_sets.cmd_chase_view, "[uuid]"),
99
- Command("edit", "Edit name, description, or visibility", custom_sets.cmd_chase_edit),
99
+ Command("edit", "Edit name, description, visibility, or granularity", custom_sets.cmd_chase_edit),
100
+ Command("tune", "Retune one entry — match mode or player-slot qualifiers", custom_sets.cmd_chase_tune),
100
101
  Command("list", "Your sets (created + subscribed)", custom_sets.cmd_chase_list),
101
102
  Command("find", "Discover public sets by name", custom_sets.cmd_chase_find, "[query]"),
102
103
  Command("subscribe", "Subscribe to / unsubscribe from a public set", custom_sets.cmd_chase_subscribe, "[query]"),
103
- Command("remove", "Remove a card from a set", custom_sets.cmd_chase_remove),
104
+ Command("remove", "Remove an entry from a set", custom_sets.cmd_chase_remove),
104
105
  Command("delete", "Delete a set you created", custom_sets.cmd_chase_delete),
105
106
  ]),
106
107
  Group("collector", "your identity", [
@@ -24,7 +24,7 @@ from slab_schemas.pricing import CardComps, CardMarket, PortfolioSummary, SetTop
24
24
  from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
25
25
  from slab_schemas.timeseries import CardPriceHistory
26
26
 
27
- from .browse import browse
27
+ from .browse import Page, browse
28
28
  from .theme import (
29
29
  bar,
30
30
  console,
@@ -102,7 +102,25 @@ CARD_COLUMNS = [
102
102
  ("Finish", {"style": "slab.accent"}),
103
103
  ("Attrs", {"style": "slab.dim"}),
104
104
  ("Run", {"justify": "right", "style": "slab.dim"}),
105
- ("UUID", {"style": "slab.muted"}),
105
+ ]
106
+
107
+ # The collapsed shape: one row per CARD, not per printing. Finish and Run are the two columns that
108
+ # vary *within* a slot, so they move down to the printings level and their place is taken by the
109
+ # count of printings there — the row says "this card exists 17 ways", the level below says which.
110
+ SLOT_COLUMNS = [
111
+ ("Number", {"style": "slab.value"}),
112
+ ("Subject(s)", {}),
113
+ ("Set", {}),
114
+ ("Subset", {}),
115
+ ("Attrs", {"style": "slab.dim"}),
116
+ ("Printings", {"justify": "right", "style": "slab.accent"}),
117
+ ]
118
+
119
+ PRINTING_COLUMNS = [
120
+ ("Finish", {"style": "slab.accent"}),
121
+ ("Run", {"justify": "right"}),
122
+ ("Attrs", {"style": "slab.dim"}),
123
+ ("Odds", {"style": "slab.dim"}),
106
124
  ]
107
125
 
108
126
 
@@ -112,21 +130,69 @@ def card_cells(c: CardOut) -> list[str]:
112
130
  attrs = ", ".join(a.name for a in c.attributes)
113
131
  finish = c.finish or "[slab.dim]Base[/]"
114
132
  pr = f"/{c.print_run}" if c.print_run else ""
115
- return [c.card_number, subjects, c.set_name or "", c.subset or "", finish, attrs, pr, c.uuid[:8]]
133
+ return [c.card_number, subjects, c.set_name or "", c.subset or "", finish, attrs, pr]
134
+
135
+
136
+ def slot_cells(c: CardOut) -> list[str]:
137
+ """One card SLOT as its SLOT_COLUMNS cells — the base card plus how many printings it has."""
138
+ subjects = ", ".join(s.name for s in c.subjects)
139
+ attrs = ", ".join(a.name for a in c.attributes)
140
+ n = c.printing_count
141
+ # A card with no parallels is a dead end, so it reads as a dim 1 rather than an invitation.
142
+ printings = "[slab.dim]1[/]" if not n or n <= 1 else str(n)
143
+ return [c.card_number, subjects, c.set_name or "", c.subset or "", attrs, printings]
144
+
145
+
146
+ def printing_cells(c: CardOut) -> list[str]:
147
+ """One printing of a slot as its PRINTING_COLUMNS cells — what differs between rainbow rows."""
148
+ attrs = ", ".join(a.name for a in c.attributes)
149
+ return [
150
+ c.finish or "[slab.dim]Base[/]",
151
+ f"/{c.print_run}" if c.print_run else "[slab.dim]—[/]",
152
+ attrs,
153
+ c.odds or "",
154
+ ]
155
+
156
+
157
+ def card_title(c: CardOut) -> str:
158
+ """A single card named for a heading: '#86 Paul Coffey — 2021-22 Stature'."""
159
+ subjects = ", ".join(s.name for s in c.subjects) or "—"
160
+ return f"#{c.card_number} {subjects} — {c.set_name or ''} {c.subset or ''}".strip()
116
161
 
117
162
 
118
- def browse_cards(result: CardSearchResult, *, detail=None) -> CardOut | None:
119
- """Browse a card search. With `detail` (a `CardOut -> None` renderer), Enter drills into
120
- that view as a page; Esc climbs back. Static table on non-interactive output."""
163
+ def browse_cards(result: CardSearchResult, *, detail=None, printings=None) -> CardOut | None:
164
+ """Browse a card search. `detail` (a `CardOut -> None` renderer) is what Enter opens on a
165
+ PRINTING; Esc climbs back. Static table on non-interactive output.
166
+
167
+ With `printings` (a `CardOut -> list[CardOut]` loader, i.e. the parallels endpoint) the list is
168
+ one row per card and Enter opens that card's printings as a nested list, whose Enter runs
169
+ `detail` — so a search for a player reads as his cards, not as every finish of every card.
170
+ Without it, every printing is its own row and Enter runs `detail` straight away."""
121
171
  if not result.items:
122
172
  console.print("[slab.dim]No cards found.[/]")
123
173
  return None
174
+ footer = _shown_footer(len(result.items), result.total,
175
+ "narrow your search, or `--limit N` to see more.")
176
+ if printings is None:
177
+ return browse(
178
+ CARD_COLUMNS, [(c, card_cells(c)) for c in result.items],
179
+ title="Cards", note=f"{result.total} total", hint="market view",
180
+ footer=footer, detail=detail,
181
+ )
182
+
183
+ def rainbow(card: CardOut) -> Page:
184
+ rows = printings(card)
185
+ return Page(
186
+ PRINTING_COLUMNS, [(p, printing_cells(p)) for p in rows],
187
+ title=card_title(card),
188
+ note=f"{len(rows)} printing{'' if len(rows) == 1 else 's'}",
189
+ hint="market view", detail=detail,
190
+ )
191
+
124
192
  return browse(
125
- CARD_COLUMNS, [(c, card_cells(c)) for c in result.items],
126
- title="Cards", note=f"{result.total} total", hint="market view",
127
- footer=_shown_footer(len(result.items), result.total,
128
- "narrow your search, or `--limit N` to see more."),
129
- detail=detail,
193
+ SLOT_COLUMNS, [(c, slot_cells(c)) for c in result.items],
194
+ title="Cards", note=f"{result.total} total", hint="printings",
195
+ footer=footer, detail=rainbow,
130
196
  )
131
197
 
132
198
 
@@ -162,7 +162,12 @@ _PAGING_FLAGS: list[Flag] = [
162
162
 
163
163
  # What each search command parses: the shared card grammar + its scope's extras.
164
164
 
165
- CARD_SEARCH_FLAGS: list[Flag] = CARD_FLAGS + _PAGING_FLAGS
165
+ CARD_SEARCH_FLAGS: list[Flag] = CARD_FLAGS + [
166
+ # Catalog-only: mirrors CardSearchQuery.collapse_parallels, which CollectionSearchQuery has no
167
+ # equivalent of (a collection lists the copies you own, and a copy already IS one printing).
168
+ Flag("--printings", "collapse_parallels", "bool",
169
+ "One row per printing instead of per card", store=False),
170
+ ] + _PAGING_FLAGS
166
171
 
167
172
  COLLECTION_SEARCH_FLAGS: list[Flag] = CARD_FLAGS + [
168
173
  # collection-only filters — mirrors CollectionSearchQuery's extension of CardFilter
File without changes