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.
- slab_cli/__init__.py +0 -0
- slab_cli/banner.py +92 -0
- slab_cli/client.py +386 -0
- slab_cli/commands/__init__.py +4 -0
- slab_cli/commands/breaks.py +140 -0
- slab_cli/commands/catalog.py +84 -0
- slab_cli/commands/collection.py +676 -0
- slab_cli/commands/custom_sets.py +484 -0
- slab_cli/commands/export.py +129 -0
- slab_cli/commands/lots.py +114 -0
- slab_cli/commands/pricing.py +386 -0
- slab_cli/commands/registry.py +140 -0
- slab_cli/commands/setup.py +24 -0
- slab_cli/config.py +21 -0
- slab_cli/context.py +50 -0
- slab_cli/display.py +1187 -0
- slab_cli/flags.py +189 -0
- slab_cli/help.py +87 -0
- slab_cli/main.py +95 -0
- slab_cli/paging.py +46 -0
- slab_cli/picker.py +78 -0
- slab_cli/prompts.py +549 -0
- slab_cli/sources.py +17 -0
- slab_cli/theme.py +184 -0
- slab_cli-0.1.0.dist-info/METADATA +70 -0
- slab_cli-0.1.0.dist-info/RECORD +28 -0
- slab_cli-0.1.0.dist-info/WHEEL +4 -0
- slab_cli-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -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
|
+
)
|