slab-cli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,484 @@
1
+ """Custom set commands — create, add cards, view completion, edit, remove, delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from InquirerPy import inquirer
6
+ from InquirerPy.base.control import Choice
7
+
8
+ from slab_schemas.custom_sets import (
9
+ CustomSetCardAdd,
10
+ CustomSetCreate,
11
+ CustomSetOut,
12
+ CustomSetSearchQuery,
13
+ )
14
+ from slab_schemas.enums import CustomSetType, MatchMode, Visibility
15
+
16
+ from ..client import SlabClient
17
+ from ..context import ctx
18
+ from ..display import (
19
+ console,
20
+ format_card_summary,
21
+ print_custom_set_detail,
22
+ print_custom_sets,
23
+ )
24
+ from ..picker import search_and_pick_subject
25
+ from ..prompts import (
26
+ ask_int_optional,
27
+ ask_text,
28
+ ask_text_optional,
29
+ confirm,
30
+ select_custom_set_type,
31
+ select_match_mode,
32
+ select_printing,
33
+ select_visibility,
34
+ )
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Helpers
39
+ # ---------------------------------------------------------------------------
40
+
41
+ def _select_custom_set(client: SlabClient, collector: str, message: str = "Select a set:") -> CustomSetOut | None:
42
+ """Browse the collector's custom sets and pick one."""
43
+ sets = client.collector_custom_sets(collector)
44
+ if not sets:
45
+ console.print("[dim]No custom sets yet. Use `slab chase create` to make one.[/dim]")
46
+ return None
47
+ choices = [
48
+ Choice(value=s, name=f"{s.name} ({s.set_type} · {s.card_count} cards · {s.subscriber_count} subs)")
49
+ for s in sets
50
+ ]
51
+ return inquirer.select(message=message, choices=choices, max_height="70%").execute()
52
+
53
+
54
+ def _pick_from_sets(sets: list[CustomSetOut], message: str) -> CustomSetOut | None:
55
+ """Pick one set from an arbitrary result list (discovery/search), labelling creator + whether
56
+ you're already subscribed — distinct from `_select_custom_set`, which lists only your own sets."""
57
+ if not sets:
58
+ return None
59
+ choices = []
60
+ for s in sets:
61
+ sub = " ✓ subscribed" if s.is_subscribed else ""
62
+ by = f" — by {s.creator_name}" if s.creator_name else ""
63
+ choices.append(Choice(
64
+ value=s,
65
+ name=f"{s.name} ({s.set_type} · {s.card_count} cards · {s.subscriber_count} subs){by}{sub}",
66
+ ))
67
+ return inquirer.select(message=message, choices=choices, max_height="70%").execute()
68
+
69
+
70
+ def _search_and_select_card_for_set() -> tuple[str | None, str | None]:
71
+ """Search the catalog, pick a player, pick a printing. Returns (card_uuid, subject_name) or
72
+ (None, None). Uses the shared subject picker; the only chase-specific step is the summary print
73
+ and the (uuid, subject) return shape the batch loop wants."""
74
+ picked = search_and_pick_subject(None)
75
+ if picked is None:
76
+ return None, None
77
+ cards, subject = picked
78
+
79
+ card = select_printing(cards, subject)
80
+ if card is None:
81
+ return None, None
82
+
83
+ console.print(f"\n [bold]{format_card_summary(card)}[/bold]\n")
84
+ return card.uuid, subject
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # slab chase create — create a new custom set
89
+ # ---------------------------------------------------------------------------
90
+
91
+ def cmd_chase_create(args: list[str]) -> None:
92
+ """Create a new custom set — curated or dynamic."""
93
+ collector = ctx.collector
94
+
95
+ name = ask_text("Set name:")
96
+ description = ask_text_optional("Description (enter to skip):")
97
+ set_type = select_custom_set_type()
98
+ visibility = select_visibility()
99
+
100
+ filter_json = None
101
+ if set_type == "dynamic":
102
+ console.print("\n[dim]A dynamic set is defined by a search filter. Enter the filter criteria.[/dim]")
103
+ console.print("[dim]Cards matching this filter will automatically be in the set.[/dim]\n")
104
+ # Build a CardFilter interactively
105
+ filter_json = _build_dynamic_filter()
106
+ if filter_json is None:
107
+ console.print("[dim]Cancelled.[/dim]")
108
+ return
109
+
110
+ cs = ctx.client.create_custom_set(collector, CustomSetCreate(
111
+ name=name,
112
+ description=description,
113
+ visibility=Visibility(visibility),
114
+ set_type=CustomSetType(set_type),
115
+ filter_json=filter_json,
116
+ ))
117
+
118
+ console.print(f"\n[green]Created![/green] {cs.name} [dim]({cs.uuid[:8]})[/dim]")
119
+ console.print(f" Type: {cs.set_type} | Visibility: {cs.visibility}")
120
+ console.print(f" You are auto-subscribed.\n")
121
+
122
+ if set_type == "curated" and confirm("Add cards to this set now?", default=True):
123
+ _batch_add_cards(ctx.client, collector, cs.uuid)
124
+
125
+
126
+ def _build_dynamic_filter() -> dict | None:
127
+ """Build a CardFilter dict interactively for a dynamic set.
128
+
129
+ Walks through the most common filter dimensions. Each is skippable (enter = skip).
130
+ At least one filter must be set."""
131
+ filter_parts: dict = {}
132
+
133
+ console.print("[dim] Set the criteria for this dynamic set. Press enter to skip any field.[/dim]\n")
134
+
135
+ subject = ask_text_optional("Player name:")
136
+ if subject:
137
+ filter_parts["subject"] = subject
138
+
139
+ team = ask_text_optional("Team (e.g. Maple Leafs):")
140
+ if team:
141
+ filter_parts["team"] = [team]
142
+
143
+ subset = ask_text_optional("Subset (e.g. Young Guns):")
144
+ if subset:
145
+ filter_parts["subset"] = [subset]
146
+
147
+ brand = ask_text_optional("Brand (e.g. Upper Deck):")
148
+ if brand:
149
+ filter_parts["brand"] = [brand]
150
+
151
+ year_str = ask_text_optional("Season year (e.g. 2025):")
152
+ if year_str:
153
+ try:
154
+ filter_parts["year"] = int(year_str)
155
+ except ValueError:
156
+ console.print("[yellow] Skipped — not a valid year.[/yellow]")
157
+
158
+ finish = ask_text_optional("Finish (e.g. Rainbow, Gold):")
159
+ if finish:
160
+ filter_parts["finish"] = [finish]
161
+
162
+ attribute = ask_text_optional("Attribute (e.g. Rookie, Autograph, Memorabilia):")
163
+ if attribute:
164
+ filter_parts["attribute"] = [attribute]
165
+
166
+ # Boolean filters — only ask the ones that make sense
167
+ if "attribute" not in filter_parts:
168
+ if confirm("Only rookies?", default=False):
169
+ filter_parts["rookie"] = True
170
+
171
+ if confirm("Only numbered cards?", default=False):
172
+ filter_parts["is_numbered"] = True
173
+ max_run = ask_text_optional("Max print run (e.g. 99 for /99-or-less, enter to skip):")
174
+ if max_run:
175
+ try:
176
+ filter_parts["numbered_max"] = int(max_run)
177
+ except ValueError:
178
+ pass
179
+
180
+ if "finish" not in filter_parts:
181
+ if confirm("Only base cards (no parallels)?", default=False):
182
+ filter_parts["base_only"] = True
183
+
184
+ if not filter_parts:
185
+ console.print("[yellow]No filters set — a dynamic set needs at least one.[/yellow]")
186
+ return None
187
+
188
+ console.print("\n [bold]Filter preview:[/bold]")
189
+ for k, v in filter_parts.items():
190
+ console.print(f" {k}: {v}")
191
+
192
+ if not confirm("Create with these filters?", default=True):
193
+ return None
194
+
195
+ return filter_parts
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # slab chase add — add cards to a curated set (batch)
200
+ # ---------------------------------------------------------------------------
201
+
202
+ def cmd_chase_add(args: list[str]) -> None:
203
+ """Add cards to a curated custom set — batch mode like `slab collection add`."""
204
+ collector = ctx.collector
205
+ client = ctx.client
206
+
207
+ cs = _select_custom_set(client, collector, message="Which set to add cards to?")
208
+ if cs is None:
209
+ return
210
+ if cs.set_type != "curated":
211
+ console.print("[yellow]Can only add cards to curated sets. Dynamic sets are filter-defined.[/yellow]")
212
+ return
213
+
214
+ _batch_add_cards(client, collector, cs.uuid)
215
+
216
+
217
+ def _batch_add_cards(client: SlabClient, collector: str, set_uuid: str) -> None:
218
+ """Batch loop: search → pick player → pick printing → choose match mode → add.
219
+
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."""
222
+ default_match: str | None = None
223
+ default_serial: int | None = None
224
+ added = 0
225
+
226
+ 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(
250
+ card_uuid=card_uuid,
251
+ match_mode=MatchMode(default_match),
252
+ serial_number=serial,
253
+ 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}")
260
+ console.print()
261
+ added += 1
262
+
263
+ if not confirm("Add another card to this set?", default=True):
264
+ break
265
+
266
+ if added > 0:
267
+ console.print(f"[dim]Done — {added} card(s) added to the set.[/dim]")
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # slab chase view — view a set with completion
272
+ # ---------------------------------------------------------------------------
273
+
274
+ def cmd_chase_view(args: list[str]) -> None:
275
+ """View a custom set with your completion progress. Offers to add missing cards."""
276
+ collector = ctx.collector
277
+ client = ctx.client
278
+
279
+ if args:
280
+ detail = client.get_custom_set(args[0], collector)
281
+ else:
282
+ cs = _select_custom_set(client, collector, message="Which set to view?")
283
+ if cs is None:
284
+ return
285
+ detail = client.get_custom_set(cs.uuid, collector)
286
+
287
+ print_custom_set_detail(detail)
288
+
289
+ # Offer to add missing cards to the collection
290
+ missing = [e for e in detail.cards if not e.owned]
291
+ if missing and confirm(f"Add any of the {len(missing)} missing cards to your collection?", default=False):
292
+ from .collection import _add_single_card, AddSession
293
+ session = AddSession(client=client, collector=collector)
294
+ for entry in missing:
295
+ card = entry.card
296
+ subjects = ", ".join(s.name for s in card.subjects)
297
+ console.print(f"\n [bold]{card.card_number}[/bold] {subjects} — {card.finish or 'Base'}")
298
+ if confirm("Add this missing card to your collection?", default=False):
299
+ # Pre-fill this exact printing — skip the search, go straight to acquisition details.
300
+ _add_single_card(session, card=card)
301
+ if not confirm("Continue to next missing card?", default=True):
302
+ break
303
+
304
+
305
+ def cmd_chase_edit(args: list[str]) -> None:
306
+ """Edit a custom set's name, description, or visibility."""
307
+ collector = ctx.collector
308
+ client = ctx.client
309
+
310
+ cs = _select_custom_set(client, collector, message="Which set to edit?")
311
+ if cs is None:
312
+ return
313
+
314
+ # Check ownership
315
+ detail = client.get_custom_set(cs.uuid, collector)
316
+ if detail.creator_uuid != collector:
317
+ console.print("[yellow]You can only edit sets you created.[/yellow]")
318
+ return
319
+
320
+ console.print(f"\n Current name: {cs.name}")
321
+ console.print(f" Current description: {cs.description or '—'}")
322
+ console.print(f" Current visibility: {cs.visibility}\n")
323
+
324
+ from slab_schemas.custom_sets import CustomSetUpdate
325
+
326
+ name = ask_text_optional(f"New name (enter to keep '{cs.name}'):")
327
+ description = ask_text_optional("New description (enter to keep current):")
328
+ vis = None
329
+ if confirm("Change visibility?", default=False):
330
+ vis = select_visibility()
331
+
332
+ update = CustomSetUpdate(
333
+ name=name,
334
+ description=description,
335
+ visibility=Visibility(vis) if vis else None,
336
+ )
337
+
338
+ # Only send if something changed
339
+ if not any(v is not None for v in update.model_dump(exclude_unset=True).values()):
340
+ console.print("[dim]No changes.[/dim]")
341
+ return
342
+
343
+ updated = client.update_custom_set(collector, cs.uuid, update)
344
+ console.print(f"[green]Updated![/green] {updated.name}")
345
+
346
+
347
+ # ---------------------------------------------------------------------------
348
+ # slab chase list — your custom sets
349
+ # ---------------------------------------------------------------------------
350
+
351
+ def cmd_chase_list(args: list[str]) -> None:
352
+ """List your custom sets (created + subscribed)."""
353
+ sets = ctx.client.collector_custom_sets(ctx.collector)
354
+ print_custom_sets(sets)
355
+
356
+
357
+ # ---------------------------------------------------------------------------
358
+ # slab chase find — discover public sets
359
+ # ---------------------------------------------------------------------------
360
+
361
+ def _discover_sets(client: SlabClient, collector: str, args: list[str], empty_prompt: str) -> list[CustomSetOut]:
362
+ """Search public sets (plus your own), most-subscribed first. `collector` is passed so results
363
+ carry the is_subscribed flag. Returns the matching sets (possibly empty)."""
364
+ q = " ".join(args) if args else ask_text_optional(empty_prompt)
365
+ result = client.search_custom_sets(
366
+ CustomSetSearchQuery(q=q, collector_uuid=collector, sort="-subscribers")
367
+ )
368
+ return result.items
369
+
370
+
371
+ def cmd_chase_find(args: list[str]) -> None:
372
+ """Discover public chase sets by name (your own private sets are included too). This is how you
373
+ find a set someone else published before subscribing to it with `slab chase subscribe`."""
374
+ sets = _discover_sets(ctx.client, ctx.collector, args, "Search sets by name (enter for all public):")
375
+ if not sets:
376
+ console.print("[yellow]No matching sets found.[/yellow]")
377
+ return
378
+ print_custom_sets(sets)
379
+ console.print(
380
+ "[dim]`slab chase subscribe` to track one · `slab chase view <uuid>` to see its cards.[/dim]"
381
+ )
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # slab chase subscribe — subscribe to / unsubscribe from a public set
386
+ # ---------------------------------------------------------------------------
387
+
388
+ def cmd_chase_subscribe(args: list[str]) -> None:
389
+ """Subscribe to a public chase set (or unsubscribe if already tracking it). Search by name,
390
+ pick one, and toggle. Subscribed sets show in `slab chase list` and track your completion."""
391
+ collector = ctx.collector
392
+ client = ctx.client
393
+
394
+ sets = _discover_sets(client, collector, args, "Search sets by name (enter for popular public sets):")
395
+ if not sets:
396
+ console.print("[yellow]No matching sets found.[/yellow]")
397
+ return
398
+
399
+ cs = _pick_from_sets(sets, message="Which set?")
400
+ if cs is None:
401
+ return
402
+
403
+ if cs.is_subscribed:
404
+ if confirm(f"You're already subscribed to '{cs.name}'. Unsubscribe?", default=False):
405
+ client.unsubscribe(collector, cs.uuid)
406
+ console.print(f"[green]Unsubscribed.[/green] {cs.name}")
407
+ else:
408
+ console.print("[dim]No change.[/dim]")
409
+ else:
410
+ if confirm(f"Subscribe to '{cs.name}'?", default=True):
411
+ client.subscribe(collector, cs.uuid)
412
+ console.print(f"[green]Subscribed![/green] {cs.name} [dim]— track it with `slab chase view`[/dim]")
413
+ else:
414
+ console.print("[dim]No change.[/dim]")
415
+
416
+
417
+ # ---------------------------------------------------------------------------
418
+ # slab chase remove — remove a card from a curated set
419
+ # ---------------------------------------------------------------------------
420
+
421
+ def cmd_chase_remove(args: list[str]) -> None:
422
+ """Remove a card entry from a curated set."""
423
+ collector = ctx.collector
424
+ client = ctx.client
425
+
426
+ cs = _select_custom_set(client, collector, message="Which set to remove a card from?")
427
+ if cs is None:
428
+ return
429
+ if cs.set_type != "curated":
430
+ console.print("[yellow]Can only remove cards from curated sets.[/yellow]")
431
+ return
432
+
433
+ detail = client.get_custom_set(cs.uuid, collector)
434
+ if not detail.cards:
435
+ console.print("[dim]This set has no cards.[/dim]")
436
+ return
437
+
438
+ choices = []
439
+ for entry in detail.cards:
440
+ card = entry.card
441
+ subjects = ", ".join(s.name for s in card.subjects) if card else "—"
442
+ label = f"{card.card_number} {subjects} — {entry.match_mode}"
443
+ if entry.serial_number is not None:
444
+ label += f" #{entry.serial_number}"
445
+ owned = " ✓" if entry.owned else ""
446
+ choices.append(Choice(value=entry, name=f"{label}{owned}"))
447
+
448
+ entry = inquirer.select(
449
+ message=f"Remove which card? ({len(detail.cards)} in set):",
450
+ choices=choices,
451
+ max_height="70%",
452
+ ).execute()
453
+
454
+ if entry and confirm(f"Remove this card entry? ({entry.uuid[:8]})", default=False):
455
+ client.delete_set_card(collector, cs.uuid, entry.uuid)
456
+ console.print("[green]Removed.[/green]")
457
+
458
+
459
+ # ---------------------------------------------------------------------------
460
+ # slab chase delete — delete a set you created
461
+ # ---------------------------------------------------------------------------
462
+
463
+ def cmd_chase_delete(args: list[str]) -> None:
464
+ """Delete a custom set you created — removes the set, its card entries, and every subscription."""
465
+ collector = ctx.collector
466
+ client = ctx.client
467
+
468
+ cs = _select_custom_set(client, collector, message="Which set to delete?")
469
+ if cs is None:
470
+ return
471
+
472
+ # Check ownership
473
+ detail = client.get_custom_set(cs.uuid, collector)
474
+ if detail.creator_uuid != collector:
475
+ console.print("[yellow]You can only delete sets you created.[/yellow]")
476
+ return
477
+
478
+ console.print(f"\n [bold]{cs.name}[/bold] ({cs.set_type} · {cs.card_count} cards · {cs.subscriber_count} subs)")
479
+ if not confirm("Delete this set? It disappears for every subscriber.", default=False):
480
+ console.print("[dim]Cancelled.[/dim]")
481
+ return
482
+
483
+ client.delete_custom_set(collector, cs.uuid)
484
+ console.print(f"[green]Deleted.[/green] {cs.name}")
@@ -0,0 +1,129 @@
1
+ """Export command — dump your collection to a printable CSV checklist.
2
+
3
+ Built for the physical audit / stock-take: you have a stack of cards and want to reconcile it
4
+ against what's recorded. Run `slab export`, pick one of the sets you own from the list (fuzzy — no
5
+ need to type an exact name), and you get a CSV with an `in_hand` column. Print it, tick each card
6
+ off as you match it, and the one physical card with no row is your gap. Rows are sorted by
7
+ subset → finish → card number so they walk in the same order you'd sort a stack.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import csv
13
+
14
+ from slab_schemas.collection import CollectionSearchQuery
15
+
16
+ from ..context import ctx
17
+ from ..display import console
18
+ from ..paging import fetch_all_pages
19
+ from ..prompts import select_owned_set
20
+ from .collection import _natural_chunks
21
+
22
+ _COLUMNS = ["in_hand", "subset", "finish", "card_number", "player", "serial", "print_run", "status", "cost_basis"]
23
+
24
+
25
+ def _num_key(card_number: str | None) -> tuple:
26
+ """Numeric-aware sort key mirroring the physical-stack order used in the collection walk: purely
27
+ numeric card numbers first (ascending), lettered/alphanumeric runs after — each ordered by full
28
+ natural chunks so 'B-2' sorts before 'B-10'. Reuses collection._natural_chunks so the export and
29
+ the walk sort identically (the old first-digit-run-only key mis-ordered multi-part numbers)."""
30
+ s = (card_number or "").strip()
31
+ return (0 if s.isdigit() else 1, _natural_chunks(s))
32
+
33
+
34
+ def _fetch_all(collector: str, set_slug: str | None) -> list:
35
+ """Page through the whole collection (or one set) — no row cap."""
36
+ copies, _ = fetch_all_pages(
37
+ lambda limit, offset: ctx.client.search_collection(
38
+ collector,
39
+ CollectionSearchQuery(
40
+ set_slug=[set_slug] if set_slug else None, limit=limit, offset=offset
41
+ ),
42
+ ),
43
+ )
44
+ return copies
45
+
46
+
47
+ def _owned_sets(copies: list) -> list[tuple[str, str, int]]:
48
+ """Distinct (slug, name, count) of the sets these copies belong to, sorted by count desc."""
49
+ agg: dict[str, list] = {}
50
+ for cp in copies:
51
+ c = cp.card
52
+ if not c or not c.set_slug:
53
+ continue
54
+ entry = agg.setdefault(c.set_slug, [c.set_name or c.set_slug, 0])
55
+ entry[1] += 1
56
+ return sorted(((slug, name, n) for slug, (name, n) in agg.items()), key=lambda s: (-s[2], s[1]))
57
+
58
+
59
+ def _write_csv(copies: list, out_file: str) -> None:
60
+ copies = sorted(
61
+ copies,
62
+ key=lambda cp: (
63
+ (cp.card.subset or "") if cp.card else "",
64
+ (cp.card.finish or "") if cp.card else "",
65
+ _num_key(cp.card.card_number if cp.card else None),
66
+ (cp.card.card_number or "") if cp.card else "",
67
+ ),
68
+ )
69
+ with open(out_file, "w", newline="") as fh:
70
+ writer = csv.writer(fh)
71
+ writer.writerow(_COLUMNS)
72
+ for cp in copies:
73
+ c = cp.card
74
+ players = " / ".join(s.name for s in c.subjects) if c else ""
75
+ writer.writerow([
76
+ "[ ]",
77
+ (c.subset if c else "") or "",
78
+ (c.finish if c else None) or "BASE",
79
+ (c.card_number if c else "") or "",
80
+ players,
81
+ cp.serial_number if cp.serial_number is not None else "",
82
+ (c.print_run if c else None) or "",
83
+ cp.status or "",
84
+ f"{cp.cost_basis:.2f}" if cp.cost_basis is not None else "",
85
+ ])
86
+
87
+
88
+ def cmd_export(args: list[str]) -> None:
89
+ """Export your collection to a printable CSV checklist.
90
+
91
+ Usage: slab export [--set <slug>] [--out FILE]
92
+ slab export # pick a set from a list (or the whole collection)
93
+ slab export --set <slug> # non-interactive (scripting)
94
+ slab export --out audit.csv
95
+ """
96
+ out_file: str | None = None
97
+ set_slug: str | None = None
98
+ it = iter(args)
99
+ for a in it:
100
+ if a in ("--out", "-o"):
101
+ out_file = next(it, None)
102
+ elif a == "--set":
103
+ set_slug = next(it, None)
104
+
105
+ if set_slug is None:
106
+ # Interactive: fetch the collection, let the user pick from the sets they own.
107
+ all_copies = _fetch_all(ctx.collector, None)
108
+ if not all_copies:
109
+ console.print("[yellow]Your collection is empty — nothing to export.[/yellow]")
110
+ return
111
+ picked = select_owned_set(_owned_sets(all_copies), total=len(all_copies))
112
+ set_slug = picked or None # "" → whole collection
113
+ copies = all_copies if set_slug is None else [
114
+ cp for cp in all_copies if cp.card and cp.card.set_slug == set_slug
115
+ ]
116
+ else:
117
+ copies = _fetch_all(ctx.collector, set_slug)
118
+ if not copies:
119
+ console.print(f"[yellow]No copies found for set '{set_slug}'.[/yellow]")
120
+ return
121
+
122
+ out_file = out_file or f"{set_slug or 'slab-collection'}-checklist.csv"
123
+ _write_csv(copies, out_file)
124
+
125
+ console.print(f"\n[green]Exported {len(copies)} copies[/green] → [bold]{out_file}[/bold]")
126
+ console.print(
127
+ "[dim]Open/print it and tick in_hand as you match each physical card — "
128
+ "the one card with no row is your gap.[/dim]"
129
+ )