slab-cli 0.10.0__tar.gz → 0.12.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.10.0 → slab_cli-0.12.0}/PKG-INFO +1 -1
  2. {slab_cli-0.10.0 → slab_cli-0.12.0}/pyproject.toml +1 -1
  3. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/client.py +9 -2
  4. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/commands/collection.py +13 -0
  5. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/commands/custom_sets.py +135 -47
  6. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/display.py +33 -6
  7. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/prompts.py +24 -0
  8. {slab_cli-0.10.0 → slab_cli-0.12.0}/README.md +0 -0
  9. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/__init__.py +0 -0
  10. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/banner.py +0 -0
  11. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/browse.py +0 -0
  12. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/commands/__init__.py +0 -0
  13. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/commands/breaks.py +0 -0
  14. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/commands/catalog.py +0 -0
  15. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/commands/export.py +0 -0
  16. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/commands/lots.py +0 -0
  17. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/commands/pricing.py +0 -0
  18. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/commands/registry.py +0 -0
  19. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/commands/setup.py +0 -0
  20. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/commands/update.py +0 -0
  21. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/config.py +0 -0
  22. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/context.py +0 -0
  23. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/flags.py +0 -0
  24. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/help.py +0 -0
  25. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/main.py +0 -0
  26. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/paging.py +0 -0
  27. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/picker.py +0 -0
  28. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/platforms/__init__.py +0 -0
  29. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/platforms/base.py +0 -0
  30. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/platforms/posix.py +0 -0
  31. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/platforms/windows.py +0 -0
  32. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/sources.py +0 -0
  33. {slab_cli-0.10.0 → slab_cli-0.12.0}/src/slab_cli/theme.py +0 -0
  34. {slab_cli-0.10.0 → slab_cli-0.12.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.10.0
3
+ Version: 0.12.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.1.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.10.0"
3
+ version = "0.12.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"}]
@@ -336,7 +336,14 @@ class SlabClient:
336
336
  resp = self._request("POST", "/custom-sets/search", json=body)
337
337
  return CustomSetSearchResult.model_validate(resp.json())
338
338
 
339
- def get_custom_set(self, set_uuid: str, collector_uuid: str | None = None) -> CustomSetDetail:
340
- params = {"collector_uuid": collector_uuid} if collector_uuid else {}
339
+ def get_custom_set(
340
+ self, set_uuid: str, collector_uuid: str | None = None,
341
+ limit: int | None = None, offset: int | None = None,
342
+ ) -> CustomSetDetail:
343
+ params: dict = {"collector_uuid": collector_uuid} if collector_uuid else {}
344
+ if limit is not None:
345
+ params["limit"] = limit
346
+ if offset is not None:
347
+ params["offset"] = offset
341
348
  resp = self._request("GET", f"/custom-sets/{set_uuid}", params=params)
342
349
  return CustomSetDetail.model_validate(resp.json())
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import re
6
6
  from dataclasses import dataclass, field
7
+ from datetime import date
7
8
  from decimal import Decimal
8
9
 
9
10
  from slab_schemas.cards import CardOut, CardSearchQuery
@@ -68,6 +69,7 @@ class AddSession:
68
69
  lot_uuid: str | None = None # the purchase this batch belongs to; its cost splits across cards
69
70
  release_set_uuid: str | None = None # the break's product — scopes every card search to that box
70
71
  acquired_source: str | None = None # where these came from (purchases/trades/gifts), set once
72
+ acquired_date: date | None = None # when you got them; None for break/lot cards (theirs wins)
71
73
  cards_added: int = 0
72
74
 
73
75
 
@@ -119,6 +121,14 @@ def _ensure_acquisition_context(session: AddSession) -> None:
119
121
  # Trade / gift / other → ask where these came from, once for the batch.
120
122
  session.acquired_source = pick_source(session.client, session.collector)
121
123
 
124
+ # When you got them — asked once, and ONLY for standalone cards: a break/lot card takes its
125
+ # date from the container (which already asked), and storing a second copy of it would just be
126
+ # something else to keep in sync. Enter defaults to today, so the common "adding what I bought
127
+ # today" case is one keystroke — but an old pickup can be dated correctly, which matters
128
+ # because the portfolio chart only counts a card from the day you got it.
129
+ if session.break_uuid is None and session.lot_uuid is None:
130
+ session.acquired_date = ask_date("When did you get it?")
131
+
122
132
 
123
133
  def _per_card_cost(session: AddSession) -> Decimal | None:
124
134
  """The cost that varies per card. Pack pulls derive cost from the break, and a purchase attached
@@ -164,6 +174,8 @@ def _show_review(card: CardOut, session: AddSession, acq_cost: Decimal | None,
164
174
  console.print(f" Purchase: {session.lot_uuid[:8]} [dim](cost split across the lot)[/dim]")
165
175
  if acq_cost is not None:
166
176
  console.print(f" Cost: ${acq_cost:.2f}")
177
+ if session.acquired_date:
178
+ console.print(f" Date: {session.acquired_date}")
167
179
  if session.acquired_source:
168
180
  console.print(f" Source: {session.acquired_source}")
169
181
  if self_grade:
@@ -197,6 +209,7 @@ def _submit_and_report(session: AddSession, card: CardOut, acq_cost: Decimal | N
197
209
  lot_uuid=session.lot_uuid,
198
210
  acquisition_type=session.acq_type,
199
211
  acquisition_cost=acq_cost,
212
+ acquired_date=session.acquired_date,
200
213
  acquired_source=session.acquired_source,
201
214
  self_grade=self_grade,
202
215
  grading_company=grading_company,
@@ -5,13 +5,16 @@ from __future__ import annotations
5
5
  from InquirerPy import inquirer
6
6
  from InquirerPy.base.control import Choice
7
7
 
8
+ from types import SimpleNamespace
9
+
8
10
  from slab_schemas.custom_sets import (
9
11
  CustomSetCardAdd,
10
12
  CustomSetCreate,
13
+ CustomSetDetail,
11
14
  CustomSetOut,
12
15
  CustomSetSearchQuery,
13
16
  )
14
- from slab_schemas.enums import CustomSetType, MatchMode, Visibility
17
+ from slab_schemas.enums import CustomSetType, DynamicMatch, MatchMode, Visibility
15
18
 
16
19
  from ..client import SlabClient
17
20
  from ..context import ctx
@@ -21,6 +24,7 @@ from ..display import (
21
24
  format_card_summary,
22
25
  print_custom_set_detail,
23
26
  )
27
+ from ..paging import fetch_all_pages
24
28
  from ..picker import search_and_pick_subject
25
29
  from ..prompts import (
26
30
  ask_int_optional,
@@ -28,6 +32,8 @@ from ..prompts import (
28
32
  ask_text_optional,
29
33
  confirm,
30
34
  select_custom_set_type,
35
+ select_dynamic_match,
36
+ select_entry_kind,
31
37
  select_match_mode,
32
38
  select_printing,
33
39
  select_visibility,
@@ -38,6 +44,25 @@ from ..prompts import (
38
44
  # Helpers
39
45
  # ---------------------------------------------------------------------------
40
46
 
47
+ def _fetch_full_detail(
48
+ client: SlabClient, set_uuid: str, collector: str | None
49
+ ) -> CustomSetDetail:
50
+ """The set with its WHOLE card list — the detail endpoint pages `cards` (completion stats
51
+ already cover the full set), so flows that show or iterate every entry stitch the pages back
52
+ together here. Capped at 5000 entries as a runaway bound (a `year`-only dynamic filter can
53
+ match a whole catalog); past the cap, `card_count` > len(cards) and the display says so."""
54
+ first: list = []
55
+
56
+ def fetch(limit: int, offset: int):
57
+ d = client.get_custom_set(set_uuid, collector, limit=limit, offset=offset)
58
+ if not first:
59
+ first.append(d)
60
+ return SimpleNamespace(items=d.cards, total=d.card_count)
61
+
62
+ cards, _ = fetch_all_pages(fetch, page=500, cap=5000)
63
+ return first[0].model_copy(update={"cards": cards, "offset": 0})
64
+
65
+
41
66
  def _select_custom_set(client: SlabClient, collector: str, message: str = "Select a set:") -> CustomSetOut | None:
42
67
  """Browse the collector's custom sets and pick one."""
43
68
  sets = client.collector_custom_sets(collector)
@@ -98,6 +123,7 @@ def cmd_chase_create(args: list[str]) -> None:
98
123
  visibility = select_visibility()
99
124
 
100
125
  filter_json = None
126
+ dynamic_match = "any_printing"
101
127
  if set_type == "dynamic":
102
128
  console.print("\n[dim]A dynamic set is defined by a search filter. Enter the filter criteria.[/dim]")
103
129
  console.print("[dim]Cards matching this filter will automatically be in the set.[/dim]\n")
@@ -106,6 +132,15 @@ def cmd_chase_create(args: list[str]) -> None:
106
132
  if filter_json is None:
107
133
  console.print("[dim]Cancelled.[/dim]")
108
134
  return
135
+ # One set-wide granularity. Default follows the filter's intent: a filter that names a
136
+ # finish/parallel dimension reads as a per-printing chase; anything else (team, player,
137
+ # season) reads as "one of each card — base or any parallel counts".
138
+ chasing_printings = any(
139
+ k in filter_json for k in ("finish", "parallel_only", "is_numbered", "is_1of1")
140
+ )
141
+ dynamic_match = select_dynamic_match(
142
+ default="exact" if chasing_printings else "any_printing"
143
+ )
109
144
 
110
145
  cs = ctx.client.create_custom_set(collector, CustomSetCreate(
111
146
  name=name,
@@ -113,6 +148,7 @@ def cmd_chase_create(args: list[str]) -> None:
113
148
  visibility=Visibility(visibility),
114
149
  set_type=CustomSetType(set_type),
115
150
  filter_json=filter_json,
151
+ dynamic_match=DynamicMatch(dynamic_match),
116
152
  ))
117
153
 
118
154
  console.print(f"\n[green]Created![/green] {cs.name} [dim]({cs.uuid[:8]})[/dim]")
@@ -214,53 +250,101 @@ def cmd_chase_add(args: list[str]) -> None:
214
250
  _batch_add_cards(client, collector, cs.uuid)
215
251
 
216
252
 
253
+ def _build_player_slot(position: int) -> tuple[CustomSetCardAdd, str] | None:
254
+ """The player-slot path: pick a subject, optionally narrow with qualifiers. Returns the
255
+ payload plus a display label, or None if the user backed out."""
256
+ picked = search_and_pick_subject(None, prompt="Search for the player:")
257
+ if picked is None:
258
+ return None
259
+ cards, subject_name = picked
260
+ subject_uuid = next(
261
+ (s.uuid for c in cards for s in c.subjects if s.name == subject_name and s.uuid), None)
262
+ if subject_uuid is None:
263
+ console.print("[yellow]The API did not serve a subject id — is it up to date?[/yellow]")
264
+ return None
265
+
266
+ console.print(f"\n [bold]{subject_name}[/bold] — any card of this player fills the slot.")
267
+ console.print(" [dim]Optionally narrow what counts. Enter skips each — skip all three for"
268
+ " truly any card.[/dim]")
269
+ year = ask_int_optional("Only a season? (2025 = 2025-26):")
270
+ team = ask_text_optional("Only showing them on a team? (e.g. Hurricanes):")
271
+ attribute = ask_text_optional("Require an attribute? (e.g. Autograph, Rookie):")
272
+
273
+ quals = [q for q in (
274
+ f"{year}-{str(year + 1)[-2:]}" if year else None, team or None, attribute or None) if q]
275
+ label = f"{subject_name} — any card" + (f" · {' · '.join(quals)}" if quals else "")
276
+ payload = CustomSetCardAdd(
277
+ subject_uuid=subject_uuid,
278
+ match_mode=MatchMode.any_card,
279
+ subject_year=year,
280
+ subject_team=team or None,
281
+ subject_attribute=attribute or None,
282
+ position=position,
283
+ )
284
+ return payload, label
285
+
286
+
217
287
  def _batch_add_cards(client: SlabClient, collector: str, set_uuid: str) -> None:
218
- """Batch loop: search → pick player → pick printing → choose match mode → add.
288
+ """Batch loop: choose the kind of goal (a card, or a player), build the entry, add, repeat.
219
289
 
220
- Match mode is decided once and reused (like acquisition type in `slab collection add`).
221
- Serial number is only asked once if exact_serial — reused for the batch."""
290
+ For card entries the match mode is decided once and reused (like acquisition type in
291
+ `slab collection add`); serial number likewise. Player slots ask their qualifiers each time —
292
+ they ARE the entry."""
222
293
  default_match: str | None = None
223
294
  default_serial: int | None = None
224
295
  added = 0
225
296
 
226
297
  while True:
227
- card_uuid, subject = _search_and_select_card_for_set()
228
- if card_uuid is None:
229
- if added > 0 and not confirm("Try again?", default=True):
230
- break
231
- elif added == 0:
232
- return
233
- continue
234
-
235
- # Match mode — ask once, reuse across batch
236
- if default_match is None:
237
- default_match = select_match_mode()
238
- if default_match == "exact_serial":
239
- default_serial = ask_int_optional("Which serial number (applies to all cards in this batch)?")
240
- if default_serial is None:
241
- console.print("[yellow]Serial number is required for exact_serial match.[/yellow]")
242
- default_match = None
243
- continue
244
-
245
- serial = default_serial if default_match == "exact_serial" else None
246
-
247
- entry = client.add_set_card(
248
- collector, set_uuid,
249
- CustomSetCardAdd(
298
+ kind = select_entry_kind()
299
+
300
+ if kind == "player":
301
+ built = _build_player_slot(added)
302
+ if built is None:
303
+ if added == 0 and not confirm("Try again?", default=True):
304
+ return
305
+ continue
306
+ payload, label = built
307
+ else:
308
+ card_uuid, subject = _search_and_select_card_for_set()
309
+ if card_uuid is None:
310
+ if added > 0 and not confirm("Try again?", default=True):
311
+ break
312
+ elif added == 0:
313
+ return
314
+ continue
315
+
316
+ # Match mode — ask once, reuse across batch
317
+ if default_match is None:
318
+ default_match = select_match_mode()
319
+ if default_match == "exact_serial":
320
+ default_serial = ask_int_optional("Which serial number (applies to all cards in this batch)?")
321
+ if default_serial is None:
322
+ console.print("[yellow]Serial number is required for exact_serial match.[/yellow]")
323
+ default_match = None
324
+ continue
325
+
326
+ serial = default_serial if default_match == "exact_serial" else None
327
+ payload = CustomSetCardAdd(
250
328
  card_uuid=card_uuid,
251
329
  match_mode=MatchMode(default_match),
252
330
  serial_number=serial,
253
331
  position=added,
254
- ),
255
- )
256
- match_label = entry.match_mode
257
- if entry.serial_number is not None:
258
- match_label = f"serial #{entry.serial_number}"
259
- console.print(f"[green]Added![/green] {subject or 'Card'} — {match_label}")
332
+ )
333
+ label = f"{subject or 'Card'} — " + (
334
+ f"serial #{serial}" if serial is not None else default_match)
335
+
336
+ try:
337
+ client.add_set_card(collector, set_uuid, payload)
338
+ except Exception as exc: # 409 duplicate slot etc. — keep the batch alive
339
+ console.print(f"[yellow]Not added: {exc}[/yellow]")
340
+ if not confirm("Add another entry to this set?", default=True):
341
+ break
342
+ continue
343
+ console.print(f"[green]Added![/green] {label}")
260
344
  console.print()
261
345
  added += 1
262
346
 
263
- if not confirm("Add another card to this set?", default=True):
347
+ if not confirm("Add another entry to this set?", default=True):
264
348
  break
265
349
 
266
350
  if added > 0:
@@ -277,17 +361,18 @@ def cmd_chase_view(args: list[str]) -> None:
277
361
  client = ctx.client
278
362
 
279
363
  if args:
280
- detail = client.get_custom_set(args[0], collector)
364
+ detail = _fetch_full_detail(client, args[0], collector)
281
365
  else:
282
366
  cs = _select_custom_set(client, collector, message="Which set to view?")
283
367
  if cs is None:
284
368
  return
285
- detail = client.get_custom_set(cs.uuid, collector)
369
+ detail = _fetch_full_detail(client, cs.uuid, collector)
286
370
 
287
371
  print_custom_set_detail(detail)
288
372
 
289
373
  # Offer to add missing cards to the collection
290
- missing = [e for e in detail.cards if not e.owned]
374
+ # Only card-backed entries can pre-fill an add; a player slot has no single card to offer.
375
+ missing = [e for e in detail.cards if not e.owned and e.card]
291
376
  if missing and confirm(f"Add any of the {len(missing)} missing cards to your collection?", default=False):
292
377
  from .collection import _add_single_card, AddSession
293
378
  session = AddSession(client=client, collector=collector)
@@ -311,8 +396,8 @@ def cmd_chase_edit(args: list[str]) -> None:
311
396
  if cs is None:
312
397
  return
313
398
 
314
- # Check ownership
315
- detail = client.get_custom_set(cs.uuid, collector)
399
+ # Check ownership — metadata only, skip the card list
400
+ detail = client.get_custom_set(cs.uuid, collector, limit=1)
316
401
  if detail.creator_uuid != collector:
317
402
  console.print("[yellow]You can only edit sets you created.[/yellow]")
318
403
  return
@@ -350,7 +435,7 @@ def cmd_chase_edit(args: list[str]) -> None:
350
435
 
351
436
  def _chase_detail(s) -> None:
352
437
  """The drill-in for a browsed chase set: its full card list + completion, owner-aware."""
353
- print_custom_set_detail(ctx.client.get_custom_set(s.uuid, ctx.collector))
438
+ print_custom_set_detail(_fetch_full_detail(ctx.client, s.uuid, ctx.collector))
354
439
 
355
440
 
356
441
  def cmd_chase_list(args: list[str]) -> None:
@@ -436,7 +521,7 @@ def cmd_chase_remove(args: list[str]) -> None:
436
521
  console.print("[yellow]Can only remove cards from curated sets.[/yellow]")
437
522
  return
438
523
 
439
- detail = client.get_custom_set(cs.uuid, collector)
524
+ detail = _fetch_full_detail(client, cs.uuid, collector)
440
525
  if not detail.cards:
441
526
  console.print("[dim]This set has no cards.[/dim]")
442
527
  return
@@ -444,10 +529,13 @@ def cmd_chase_remove(args: list[str]) -> None:
444
529
  choices = []
445
530
  for entry in detail.cards:
446
531
  card = entry.card
447
- subjects = ", ".join(s.name for s in card.subjects) if card else "—"
448
- label = f"{card.card_number} {subjects} — {entry.match_mode}"
449
- if entry.serial_number is not None:
450
- label += f" #{entry.serial_number}"
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"
451
539
  owned = " ✓" if entry.owned else ""
452
540
  choices.append(Choice(value=entry, name=f"{label}{owned}"))
453
541
 
@@ -475,8 +563,8 @@ def cmd_chase_delete(args: list[str]) -> None:
475
563
  if cs is None:
476
564
  return
477
565
 
478
- # Check ownership
479
- detail = client.get_custom_set(cs.uuid, collector)
566
+ # Check ownership — metadata only, skip the card list
567
+ detail = client.get_custom_set(cs.uuid, collector, limit=1)
480
568
  if detail.creator_uuid != collector:
481
569
  console.print("[yellow]You can only delete sets you created.[/yellow]")
482
570
  return
@@ -530,10 +530,15 @@ def render_dashboard(d: DashboardStats) -> None:
530
530
  if d.priced_coverage is not None:
531
531
  rows.append(("Coverage", f"{pct_coverage(float(d.priced_coverage) * 100)} [slab.dim]priced[/]"))
532
532
  # Portfolio value over time — the CLI stand-in for the portal's chart, from the same payload.
533
+ # Each point values only the copies owned that day, so the net includes cards you ADDED in
534
+ # the window, not just price movement — said plainly so it isn't read as a 90d gain.
533
535
  if len(d.portfolio_series) >= 2:
534
536
  vals = [p.portfolio_value for p in d.portfolio_series]
535
537
  net = float(vals[-1]) - float(vals[0])
536
- rows.append(("Trend 90d", f"{sparkline(vals)} {signed_money(net)}"))
538
+ rows.append((
539
+ "Trend 90d",
540
+ f"{sparkline(vals)} {signed_money(net)} [slab.dim]incl. cards added[/]",
541
+ ))
537
542
  value = section("Value", kv_grid(rows), foil=True)
538
543
 
539
544
  comp = _composition_panel(
@@ -805,7 +810,11 @@ def print_custom_set_detail(detail: CustomSetDetail) -> None:
805
810
  vis = "Public" if detail.visibility == "public" else "Private"
806
811
  sub = "subscribed" if detail.is_subscribed else "not subscribed"
807
812
 
808
- head = [f"[slab.dim]{detail.set_type} · {vis} · {sub}[/]"]
813
+ kind = detail.set_type
814
+ if detail.dynamic_match:
815
+ grain = "any printing counts" if detail.dynamic_match == "any_printing" else "exact printings"
816
+ kind = f"{detail.set_type} ({grain})"
817
+ head = [f"[slab.dim]{kind} · {vis} · {sub}[/]"]
809
818
  if detail.description:
810
819
  head.append(detail.description)
811
820
  head.append(f"[slab.label]Creator[/] {detail.creator_name or '—'} "
@@ -832,14 +841,25 @@ def print_custom_set_detail(detail: CustomSetDetail) -> None:
832
841
 
833
842
  for entry in detail.cards:
834
843
  card = entry.card
835
- subjects = ", ".join(s.name for s in card.subjects) if card else "—"
836
- slot = _slot(card) if card else "—"
837
- pr = f" /{card.print_run}" if card and card.print_run else ""
838
- label = f"{card.card_number} {subjects} [slab.dim]— {slot}{pr}[/]" if card else "—"
844
+ if card:
845
+ subjects = ", ".join(s.name for s in card.subjects)
846
+ slot = _slot(card)
847
+ pr = f" /{card.print_run}" if card.print_run else ""
848
+ label = f"{card.card_number} {subjects} [slab.dim]— {slot}{pr}[/]"
849
+ else:
850
+ # A player slot: the subject is the goal; show its qualifiers inline.
851
+ quals = " · ".join(q for q in (
852
+ f"{entry.subject_year}-{str(entry.subject_year + 1)[-2:]}"
853
+ if entry.subject_year else None,
854
+ entry.subject_team, entry.subject_attribute) if q)
855
+ label = f"{entry.subject or '—'} [slab.dim]— any card"
856
+ label += f" · {quals}[/]" if quals else "[/]"
839
857
 
840
858
  match = entry.match_mode
841
859
  if entry.match_mode == "exact_serial" and entry.serial_number is not None:
842
860
  match = f"serial #{entry.serial_number}"
861
+ elif entry.match_mode == "any_card":
862
+ match = "player"
843
863
 
844
864
  if entry.owned:
845
865
  owned = f"[slab.gain]✓[/] [slab.dim]{entry.owned_printing or ''}[/]"
@@ -848,6 +868,13 @@ def print_custom_set_detail(detail: CustomSetDetail) -> None:
848
868
  table.add_row(str(entry.position), label, match, owned)
849
869
 
850
870
  console.print(table)
871
+ if detail.card_count > len(detail.cards):
872
+ first = detail.offset + 1
873
+ last = detail.offset + len(detail.cards)
874
+ console.print(
875
+ f" [slab.dim]Showing entries {first}–{last} of {detail.card_count} "
876
+ f"(completion covers the full set).[/]"
877
+ )
851
878
  console.print()
852
879
 
853
880
 
@@ -436,6 +436,15 @@ def select_match_mode() -> str:
436
436
  return inquirer.select(message="Match mode:", choices=choices).execute()
437
437
 
438
438
 
439
+ def select_entry_kind() -> str:
440
+ """The first fork when adding to a chase set: is the goal a card, or a player?"""
441
+ choices = [
442
+ Choice(value="card", name="A specific card — search the catalog, pick the printing"),
443
+ Choice(value="player", name="A player — any card of them counts (optionally narrowed)"),
444
+ ]
445
+ return inquirer.select(message="What are you chasing?", choices=choices).execute()
446
+
447
+
439
448
  def select_visibility() -> str:
440
449
  choices = [
441
450
  Choice(value="private", name="Private — only you can see it"),
@@ -452,6 +461,21 @@ def select_custom_set_type() -> str:
452
461
  return inquirer.select(message="Set type:", choices=choices).execute()
453
462
 
454
463
 
464
+ def select_dynamic_match(default: str = "any_printing") -> str:
465
+ """The set-wide granularity of a dynamic set — what counts as completing an entry."""
466
+ choices = [
467
+ Choice(value="any_printing",
468
+ name="Any printing — one entry per card; base or any parallel completes it"),
469
+ Choice(value="any_card",
470
+ name="Any card — one entry per PLAYER; any matching card completes it (roster chase)"),
471
+ Choice(value="exact",
472
+ name="Exact — every matching parallel is its own entry to chase"),
473
+ ]
474
+ return inquirer.select(
475
+ message="What counts as having an entry?", choices=choices, default=default,
476
+ ).execute()
477
+
478
+
455
479
  def select_cost_category() -> str:
456
480
  choices = [
457
481
  Choice(value="grading", name="Grading fee"),
File without changes