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,676 @@
1
+ """Collection commands — add, remove, cost, search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from decimal import Decimal
8
+
9
+ from slab_schemas.cards import CardOut, CardSearchQuery
10
+ from slab_schemas.collection import (
11
+ CardCopyCostCreate,
12
+ CardCopyCostUpdate,
13
+ CardCopyCreate,
14
+ CardCopyUpdate,
15
+ CollectionSearchQuery,
16
+ )
17
+ from slab_schemas.enums import AcquisitionType, CopyStatus, CostCategory, grade_delta
18
+
19
+ from ..client import ApiError, SlabClient
20
+ from ..context import ctx
21
+ from ..flags import COLLECTION_SEARCH_FLAGS, parse_flags
22
+ from ..paging import fetch_all_pages
23
+ from ..picker import pick_card
24
+ from ..display import (
25
+ console,
26
+ format_card_summary,
27
+ format_grade_delta,
28
+ print_collection,
29
+ render_dashboard,
30
+ )
31
+ from ..prompts import (
32
+ ask_date,
33
+ ask_decimal,
34
+ ask_decimal_optional,
35
+ ask_int_optional,
36
+ ask_text,
37
+ ask_text_optional,
38
+ confirm,
39
+ select_acquisition_type,
40
+ select_copy,
41
+ select_cost_action,
42
+ select_cost_category,
43
+ select_cost_entry,
44
+ select_grade,
45
+ select_grading_company,
46
+ select_printing_for_slot,
47
+ walk_prompt,
48
+ )
49
+ from .breaks import pick_or_create_break
50
+ from .lots import pick_or_create_lot
51
+ from ..sources import pick_source
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # cmd_add — decomposed into focused helpers
56
+ # ---------------------------------------------------------------------------
57
+
58
+ @dataclass
59
+ class AddSession:
60
+ """Mutable state carried across a batch add session. Acquisition type, the break/purchase-lot,
61
+ and the box scope are decided once and persist for every card added back-to-back."""
62
+ client: SlabClient
63
+ collector: str
64
+ acq_type: AcquisitionType | None = None
65
+ break_uuid: str | None = None
66
+ lot_uuid: str | None = None # the purchase this batch belongs to; its cost splits across cards
67
+ release_set_uuid: str | None = None # the break's product — scopes every card search to that box
68
+ acquired_source: str | None = None # where these came from (purchases/trades/gifts), set once
69
+ cards_added: int = 0
70
+
71
+
72
+ def _search_and_select_card(session: AddSession) -> CardOut | None:
73
+ """Find the exact card the user got, SUBJECT-FIRST: (1) search a player and pick them, (2) pick
74
+ the set they're in, then (3) pick the exact printing — base, parallel, insert, auto. Step 2 is
75
+ what stops a player who appears across many products from dumping into one undifferentiated list
76
+ (the set name also rides each row when the list still spans sets). When a break is in play every
77
+ search is scoped to that box (its release), so the set step auto-collapses and you only ever see
78
+ cards that could have come from what you opened. Shares the picker with the rest of the CLI; the
79
+ only add-specific bits are the next/first prompt and the box-scoped 'not found' message."""
80
+ prompt = "Search for next card (player):" if session.cards_added > 0 else "Search for a card (player):"
81
+ not_found = (
82
+ "No cards found in this box. Try a different player name."
83
+ if session.release_set_uuid
84
+ else "No cards found. Try a different player name."
85
+ )
86
+ scope = {"release": [session.release_set_uuid]} if session.release_set_uuid else {}
87
+ # Narrow by set unless already box-scoped (a break's cards are all one set, so it's redundant).
88
+ narrow_set = session.release_set_uuid is None
89
+ return pick_card(None, prompt=prompt, not_found=not_found, narrow_set=narrow_set, **scope)
90
+
91
+
92
+ def _ensure_acquisition_context(session: AddSession) -> None:
93
+ """Decide acquisition type once per batch — and for a pack pull the break, or for a purchase an
94
+ optional lot — BEFORE searching. A pack pull's break also scopes every search to that box.
95
+ Persists across back-to-back adds."""
96
+ if session.acq_type is not None:
97
+ return
98
+ session.acq_type = AcquisitionType(select_acquisition_type())
99
+ if session.acq_type == AcquisitionType.pack_pull:
100
+ brk = pick_or_create_break(session.client, session.collector)
101
+ if brk is not None:
102
+ session.break_uuid = brk.uuid
103
+ session.release_set_uuid = brk.set_uuid # scope every search to this box
104
+ elif session.acq_type == AcquisitionType.purchase:
105
+ # A purchase may be a multi-card buy (an eBay bundle). Offer to attach it to a lot whose
106
+ # cost splits across the cards — decided once for the batch. If a lot is chosen, its source
107
+ # and per-card cost come from the split, so we skip those per-card prompts.
108
+ if confirm("Was this part of a multi-card purchase (an eBay bundle, a show pickup)?", default=False):
109
+ lot = pick_or_create_lot(session.client, session.collector)
110
+ if lot is not None:
111
+ session.lot_uuid = lot.uuid
112
+ session.acquired_source = lot.source
113
+ if session.lot_uuid is None:
114
+ # A one-off buy → ask where it came from, once for the batch.
115
+ session.acquired_source = pick_source(session.client, session.collector)
116
+ else:
117
+ # Trade / gift / other → ask where these came from, once for the batch.
118
+ session.acquired_source = pick_source(session.client, session.collector)
119
+
120
+
121
+ def _per_card_cost(session: AddSession) -> Decimal | None:
122
+ """The cost that varies per card. Pack pulls derive cost from the break, and a purchase attached
123
+ to a lot derives cost from the lot's split (so None in both cases); a one-off purchase/trade asks
124
+ each time."""
125
+ if session.break_uuid or session.lot_uuid:
126
+ return None
127
+ if session.acq_type == AcquisitionType.purchase:
128
+ return ask_decimal_optional("Purchase price (enter to skip):")
129
+ if session.acq_type == AcquisitionType.trade:
130
+ return ask_decimal_optional("Estimated trade value (enter to skip):")
131
+ return None
132
+
133
+
134
+ def _collect_grade_details() -> tuple[str | None, str | None, str | None, str | None]:
135
+ """Get the collector's self-grade plus any professional grading. Both can coexist — a self-grade
136
+ is your own rung guess (kept forever), the grading fields are the pro verdict.
137
+ Returns (self_grade, grading_company, grade, cert_number)."""
138
+ self_grade = select_grade("Your grade assessment:")
139
+
140
+ grading_company = grade = cert_number = None
141
+ if confirm("Is it professionally graded?", default=False):
142
+ grading_company = select_grading_company()
143
+ grade = ask_text_optional("Pro grade (e.g. 10, 9.5, Authentic — enter to skip):")
144
+ cert_number = ask_text_optional("Cert number (enter to skip):")
145
+
146
+ return self_grade, grading_company, grade, cert_number
147
+
148
+
149
+ def _show_review(card: CardOut, session: AddSession, acq_cost: Decimal | None,
150
+ break_uuid: str | None, self_grade: str | None, grading_company: str | None,
151
+ grade: str | None, serial: int | None) -> None:
152
+ """Print a summary for the user to confirm before submitting."""
153
+ subjects = ", ".join(s.name for s in card.subjects)
154
+ console.print("\n [bold]Review:[/bold]")
155
+ console.print(f" Card: {card.card_number} {subjects}")
156
+ console.print(f" Acquired: {session.acq_type.value}")
157
+ if break_uuid:
158
+ console.print(f" Break: {break_uuid[:8]}")
159
+ if session.lot_uuid:
160
+ console.print(f" Purchase: {session.lot_uuid[:8]} [dim](cost split across the lot)[/dim]")
161
+ if acq_cost is not None:
162
+ console.print(f" Cost: ${acq_cost:.2f}")
163
+ if session.acquired_source:
164
+ console.print(f" Source: {session.acquired_source}")
165
+ if self_grade:
166
+ console.print(f" Self grade: {self_grade}")
167
+ if grading_company:
168
+ console.print(f" Pro grade: {grading_company} {grade or '—'}")
169
+ delta = format_grade_delta(grade_delta(self_grade, grade))
170
+ if delta:
171
+ console.print(f" Delta: {delta}")
172
+ if serial is not None and card.print_run:
173
+ console.print(f" Serial: {serial}/{card.print_run}")
174
+
175
+
176
+ def _prompt_serial(card: CardOut) -> int | None:
177
+ """Ask for the individual serial number, but only for a numbered printing."""
178
+ if not card.print_run:
179
+ return None
180
+ return ask_int_optional(f"Serial number (of /{card.print_run}, enter to skip):")
181
+
182
+
183
+ def _submit_and_report(session: AddSession, card: CardOut, acq_cost: Decimal | None,
184
+ break_uuid: str | None, self_grade: str | None,
185
+ grading_company: str | None, grade: str | None,
186
+ cert_number: str | None, serial: int | None) -> None:
187
+ """Build the CardCopyCreate, POST it, and print the Added confirmation. Shared by the
188
+ single-card `add` flow and the break-fill checklist walker, so the submit + report block
189
+ lives in exactly one place."""
190
+ payload = CardCopyCreate(
191
+ card_uuid=card.uuid,
192
+ break_uuid=break_uuid,
193
+ lot_uuid=session.lot_uuid,
194
+ acquisition_type=session.acq_type,
195
+ acquisition_cost=acq_cost,
196
+ acquired_source=session.acquired_source,
197
+ self_grade=self_grade,
198
+ grading_company=grading_company,
199
+ grade=grade,
200
+ cert_number=cert_number,
201
+ serial_number=serial,
202
+ )
203
+
204
+ copy = session.client.add_copy(session.collector, payload)
205
+ subjects = ", ".join(s.name for s in card.subjects)
206
+ console.print(f"\n[green]Added![/green] {card.card_number} {subjects} [dim]({copy.uuid[:8]})[/dim]")
207
+ delta = format_grade_delta(copy.grade_delta)
208
+ if delta:
209
+ console.print(f" Self {copy.self_grade} vs {copy.grading_company} {copy.grade}: [bold]{delta}[/bold]")
210
+ if copy.acquisition_cost is not None:
211
+ console.print(f" Acquisition cost: ${copy.acquisition_cost:.2f}")
212
+ if copy.cost_basis is not None:
213
+ console.print(f" Cost basis: ${copy.cost_basis:.2f}")
214
+ if copy.market and copy.market.fair_market_value is not None:
215
+ fmv = copy.market.fair_market_value
216
+ conf = " [yellow](low confidence)[/]" if copy.market.low_confidence else ""
217
+ console.print(f" Market value: [bold]${fmv:,.2f}[/]{conf}")
218
+ if copy.market.unrealized_gain_loss is not None:
219
+ g = copy.market.unrealized_gain_loss
220
+ color = "green" if g >= 0 else "red"
221
+ sign = "+" if g >= 0 else ""
222
+ console.print(f" Unrealized: [{color}]{sign}${g:,.2f}[/]")
223
+ console.print()
224
+
225
+ session.cards_added += 1
226
+
227
+
228
+ def _add_single_card(session: AddSession, card: CardOut | None = None) -> bool:
229
+ """Run one add-card iteration. Returns True if the card was added, False if skipped/cancelled.
230
+
231
+ When `card` is given (e.g. from a chase-set missing-card walk) the catalog search is skipped and
232
+ that exact printing is used — you go straight to the acquisition details."""
233
+ # Decide how these cards were acquired (and the break/box) up front, so the search is scoped.
234
+ _ensure_acquisition_context(session)
235
+
236
+ if card is None:
237
+ card = _search_and_select_card(session)
238
+ if card is None:
239
+ return False
240
+
241
+ console.print(f"\n [bold]{format_card_summary(card)}[/bold]\n")
242
+
243
+ acq_cost = _per_card_cost(session)
244
+ break_uuid = session.break_uuid
245
+ self_grade, grading_company, grade, cert_number = _collect_grade_details()
246
+ serial = _prompt_serial(card)
247
+
248
+ _show_review(card, session, acq_cost, break_uuid, self_grade, grading_company, grade, serial)
249
+
250
+ if not confirm("Add this card?", default=True):
251
+ console.print("[dim]Skipped.[/dim]\n")
252
+ return False
253
+
254
+ _submit_and_report(session, card, acq_cost, break_uuid, self_grade,
255
+ grading_company, grade, cert_number, serial)
256
+ return True
257
+
258
+
259
+ def cmd_add(args: list[str]) -> None:
260
+ """Add card(s) to your collection — interactive with batch support."""
261
+ session = AddSession(client=ctx.client, collector=ctx.collector)
262
+
263
+ while True:
264
+ _add_single_card(session)
265
+
266
+ if not confirm("Add another card?", default=True):
267
+ break
268
+
269
+ if session.cards_added > 0:
270
+ console.print(f"[dim]Done — {session.cards_added} card(s) added.[/dim]")
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # cmd_break_fill — walk a break's checklist, adding the cards you pulled
275
+ # ---------------------------------------------------------------------------
276
+
277
+ _CHUNK_RE = re.compile(r"\d+|\D+")
278
+
279
+
280
+ def _natural_chunks(s: str) -> tuple:
281
+ """Split a card number into comparable (kind, value) chunks so 'B-2' sorts before 'B-10'.
282
+ Digit runs compare numerically, letter runs lexically; the leading kind flag (0=text, 1=num)
283
+ keeps mixed tuples orderable."""
284
+ return tuple((1, int(t)) if t.isdigit() else (0, t.lower()) for t in _CHUNK_RE.findall(s))
285
+
286
+
287
+ def _serial_key(card: CardOut) -> tuple:
288
+ """Physical-stack order: base cards (purely numeric card numbers) first, ascending numerically
289
+ (1, 2, ... 600); then alpha-prefixed inserts, alphabetical by their code (AA-SP before PV-ID).
290
+ The server's card_number sort is lexicographic ('10' < '2'), so we re-sort here."""
291
+ s = (card.card_number or "").strip()
292
+ return (0 if s.isdigit() else 1, _natural_chunks(s))
293
+
294
+
295
+ def _load_checklist(client: SlabClient, collector: str, set_uuid: str) -> list[CardOut]:
296
+ """Fetch the checklist spine — one row per slot (base printings of every subset, inserts
297
+ included), scoped to the opened box — paging past the 200-per-request cap, then sort into
298
+ physical-stack order. `collector` annotates each slot with owned_quantity for a have/need walk."""
299
+ slots, _ = fetch_all_pages(
300
+ lambda limit, offset: client.search_cards(CardSearchQuery(
301
+ release=[set_uuid],
302
+ base_only=True,
303
+ collector=collector,
304
+ sort="card_number",
305
+ limit=limit,
306
+ offset=offset,
307
+ )),
308
+ )
309
+ slots.sort(key=_serial_key)
310
+ return slots
311
+
312
+
313
+ def _add_from_slot(session: AddSession, base_card: CardOut) -> None:
314
+ """You said yes to a slot: pick the exact printing (base default, parallels on demand), then
315
+ the same grade/serial steps as `add`. Cost is derived from the break (pack pull), so nothing
316
+ to type."""
317
+ printings = session.client.get_parallels(base_card.uuid)
318
+ printing = select_printing_for_slot(printings)
319
+ if printing is None:
320
+ return
321
+
322
+ console.print(f"\n [bold]{format_card_summary(printing)}[/bold]\n")
323
+ self_grade, grading_company, grade, cert_number = _collect_grade_details()
324
+ serial = _prompt_serial(printing)
325
+
326
+ _show_review(printing, session, None, session.break_uuid,
327
+ self_grade, grading_company, grade, serial)
328
+
329
+ if confirm("Add this card?", default=True):
330
+ _submit_and_report(session, printing, None, session.break_uuid,
331
+ self_grade, grading_company, grade, cert_number, serial)
332
+ else:
333
+ console.print("[dim]Skipped.[/dim]\n")
334
+
335
+
336
+ def _walk_checklist(session: AddSession, slots: list[CardOut]) -> None:
337
+ """Step through the slots in physical-stack order. Enter skips, y adds, a card number jumps
338
+ to that slot, q quits."""
339
+ # First occurrence wins if two subsets happen to share a bare number (inserts are prefixed).
340
+ index_by_number: dict[str, int] = {}
341
+ for idx, s in enumerate(slots):
342
+ key = (s.card_number or "").strip().lower()
343
+ index_by_number.setdefault(key, idx)
344
+
345
+ i = 0
346
+ while 0 <= i < len(slots):
347
+ action, value = walk_prompt(slots[i], (i + 1, len(slots)))
348
+ if action == "quit":
349
+ break
350
+ if action == "skip":
351
+ i += 1
352
+ elif action == "add":
353
+ _add_from_slot(session, slots[i])
354
+ i += 1
355
+ elif action == "jump":
356
+ target = index_by_number.get((value or "").strip().lower())
357
+ if target is None:
358
+ console.print(f"[yellow]#{value} isn't in this checklist.[/yellow]")
359
+ else:
360
+ i = target
361
+
362
+
363
+ def cmd_break_fill(args: list[str]) -> None:
364
+ """Walk a break's checklist card by card, adding the ones you pulled — no player names to type,
365
+ they come from the catalog. Cards are ordered like your physical stack: base serials first,
366
+ then inserts alphabetical by code."""
367
+ client, collector = ctx.client, ctx.collector
368
+
369
+ brk = pick_or_create_break(client, collector)
370
+ if brk is None:
371
+ console.print("[dim]No break selected.[/dim]")
372
+ return
373
+
374
+ console.print(f"\n[bold]Filling {brk.set_name}[/bold] — walking the checklist in serial order.")
375
+ console.print("[dim] Enter to skip · y to add · type a card number to jump · q to finish.[/dim]")
376
+
377
+ slots = _load_checklist(client, collector, brk.set_uuid)
378
+ if not slots:
379
+ console.print("[yellow]No cards found for this set.[/yellow]")
380
+ return
381
+
382
+ session = AddSession(
383
+ client=client,
384
+ collector=collector,
385
+ acq_type=AcquisitionType.pack_pull,
386
+ break_uuid=brk.uuid,
387
+ release_set_uuid=brk.set_uuid,
388
+ )
389
+ _walk_checklist(session, slots)
390
+
391
+ if session.cards_added > 0:
392
+ console.print(f"[dim]Done — {session.cards_added} card(s) added to {brk.set_name}.[/dim]")
393
+
394
+
395
+ # ---------------------------------------------------------------------------
396
+ # cmd_lot_fill — rapid-add the cards in one purchase (person-by-person)
397
+ # ---------------------------------------------------------------------------
398
+
399
+ def cmd_lot_fill(args: list[str]) -> None:
400
+ """Add the cards from one purchase back-to-back — you pick the lot ONCE and its cost splits
401
+ across them, so there's no price to type per card (just grade/serial). Unlike `break fill` there
402
+ is no checklist to walk: a purchase isn't tied to a set, so you search each card by player
403
+ (player → set → card). It's `collection add` pre-scoped to this purchase — the repetitive parts
404
+ (re-choosing the lot, entering a price) removed."""
405
+ client, collector = ctx.client, ctx.collector
406
+
407
+ lot = pick_or_create_lot(client, collector)
408
+ if lot is None:
409
+ console.print("[dim]No purchase selected.[/dim]")
410
+ return
411
+
412
+ console.print(f"\n[bold]Filling purchase {lot.uuid[:8]}[/bold] — ${lot.total_cost:.2f}"
413
+ + (f" · {lot.source}" if lot.source else "") + ".")
414
+ console.print("[dim] Add each card you bought; its cost comes from the split.[/dim]")
415
+
416
+ session = AddSession(
417
+ client=client,
418
+ collector=collector,
419
+ acq_type=AcquisitionType.purchase,
420
+ lot_uuid=lot.uuid,
421
+ acquired_source=lot.source,
422
+ )
423
+
424
+ while True:
425
+ _add_single_card(session)
426
+ if not confirm("Add another card from this purchase?", default=True):
427
+ break
428
+
429
+ if session.cards_added > 0:
430
+ console.print(f"[dim]Done — {session.cards_added} card(s) added to purchase {lot.uuid[:8]}.[/dim]")
431
+
432
+
433
+ # ---------------------------------------------------------------------------
434
+ # cmd_remove
435
+ # ---------------------------------------------------------------------------
436
+
437
+ def cmd_remove(args: list[str]) -> None:
438
+ """Find and remove a card from your collection."""
439
+ q = ask_text("Search your collection:")
440
+ result = ctx.client.search_collection(ctx.collector, CollectionSearchQuery(q=q, limit=25))
441
+ if not result.items:
442
+ console.print("[yellow]No matching cards in your collection.[/yellow]")
443
+ return
444
+
445
+ copy = select_copy(result.items, message=f"Select the copy to remove ({result.total} found):")
446
+ if copy is None:
447
+ return
448
+
449
+ if confirm(f"Remove this copy? ({copy.uuid[:8]})", default=False):
450
+ ctx.client.delete_copy(ctx.collector, copy.uuid)
451
+ console.print("[green]Removed.[/green]")
452
+ else:
453
+ console.print("[dim]Cancelled.[/dim]")
454
+
455
+
456
+ # ---------------------------------------------------------------------------
457
+ # cmd_cost
458
+ # ---------------------------------------------------------------------------
459
+
460
+ def _print_cost_ledger(copy) -> None:
461
+ """Show a copy's existing post-acquisition costs."""
462
+ console.print("\n [bold]Existing costs:[/bold]")
463
+ for c in copy.costs:
464
+ parts = [f"${c.amount:.2f}"]
465
+ if c.vendor:
466
+ parts.append(c.vendor)
467
+ if c.incurred_date:
468
+ parts.append(str(c.incurred_date))
469
+ desc = f" — {c.description}" if c.description else ""
470
+ console.print(f" {c.cost_category}: " + " · ".join(parts) + desc)
471
+
472
+
473
+ def _show_updated_basis(copy) -> None:
474
+ """Re-fetch the copy and print its recomputed cost basis after a ledger change."""
475
+ card_num = copy.card.card_number if copy.card else None
476
+ if not card_num:
477
+ return
478
+ try:
479
+ updated = ctx.client.search_collection(
480
+ ctx.collector, CollectionSearchQuery(card_number=card_num, limit=10)
481
+ )
482
+ for cp in updated.items:
483
+ if cp.uuid == copy.uuid and cp.cost_basis is not None:
484
+ console.print(f" Updated cost basis: ${cp.cost_basis:.2f}")
485
+ break
486
+ except ApiError:
487
+ pass
488
+
489
+
490
+ def _cost_add(copy) -> None:
491
+ """Log a new post-acquisition cost on a copy."""
492
+ category = select_cost_category()
493
+ amount = ask_decimal("Amount:")
494
+ vendor = ask_text_optional("Vendor (e.g. PSA, USPS — enter to skip):")
495
+ description = ask_text_optional("Description (enter to skip):")
496
+
497
+ ctx.client.add_cost(
498
+ ctx.collector,
499
+ copy.uuid,
500
+ CardCopyCostCreate(
501
+ cost_category=CostCategory(category),
502
+ amount=amount,
503
+ vendor=vendor,
504
+ description=description,
505
+ ),
506
+ )
507
+ console.print(f"\n[green]Cost added![/green] {category}: ${amount:.2f}")
508
+ if vendor:
509
+ console.print(f" Vendor: {vendor}")
510
+ _show_updated_basis(copy)
511
+
512
+
513
+ def _cost_edit(copy) -> None:
514
+ """Edit an existing cost entry — each field is optional (enter to keep the current value)."""
515
+ cost = select_cost_entry(copy.costs, message="Edit which cost?")
516
+ if cost is None:
517
+ return
518
+
519
+ console.print(f"\n Editing [bold]{cost.cost_category}[/bold] ${cost.amount:.2f}"
520
+ + (f" · {cost.vendor}" if cost.vendor else ""))
521
+
522
+ changes: dict = {}
523
+ if confirm("Change the category?", default=False):
524
+ changes["cost_category"] = CostCategory(select_cost_category())
525
+ new_amount = ask_decimal_optional(f"New amount (enter to keep ${cost.amount:.2f}):")
526
+ if new_amount is not None:
527
+ changes["amount"] = new_amount
528
+ new_vendor = ask_text_optional("New vendor (enter to keep):")
529
+ if new_vendor is not None:
530
+ changes["vendor"] = new_vendor
531
+ new_desc = ask_text_optional("New description (enter to keep):")
532
+ if new_desc is not None:
533
+ changes["description"] = new_desc
534
+
535
+ if not changes:
536
+ console.print("[dim]No changes.[/dim]")
537
+ return
538
+
539
+ updated = ctx.client.update_cost(ctx.collector, copy.uuid, cost.uuid, CardCopyCostUpdate(**changes))
540
+ console.print(f"\n[green]Cost updated![/green] {updated.cost_category}: ${updated.amount:.2f}")
541
+ _show_updated_basis(copy)
542
+
543
+
544
+ def _cost_delete(copy) -> None:
545
+ """Delete a cost entry from a copy's ledger."""
546
+ cost = select_cost_entry(copy.costs, message="Delete which cost?")
547
+ if cost is None:
548
+ return
549
+
550
+ label = f"{cost.cost_category} ${cost.amount:.2f}" + (f" · {cost.vendor}" if cost.vendor else "")
551
+ if not confirm(f"Delete this cost? ({label})", default=False):
552
+ console.print("[dim]Cancelled.[/dim]")
553
+ return
554
+
555
+ ctx.client.delete_cost(ctx.collector, copy.uuid, cost.uuid)
556
+ console.print("[green]Deleted.[/green]")
557
+ _show_updated_basis(copy)
558
+
559
+
560
+ def cmd_cost(args: list[str]) -> None:
561
+ """Manage a copy's post-acquisition costs (grading, shipping, …): add a new one, or fix/remove
562
+ an existing one. A copy with no costs yet goes straight to adding one."""
563
+ q = ask_text("Search your collection:")
564
+ result = ctx.client.search_collection(ctx.collector, CollectionSearchQuery(q=q, limit=25))
565
+ if not result.items:
566
+ console.print("[yellow]No matching cards in your collection.[/yellow]")
567
+ return
568
+
569
+ copy = select_copy(result.items, message="Select the copy:")
570
+ if copy is None:
571
+ return
572
+
573
+ # With an existing ledger, offer the full add/edit/delete choice; otherwise just add.
574
+ if copy.costs:
575
+ _print_cost_ledger(copy)
576
+ action = select_cost_action()
577
+ if action is None:
578
+ console.print("[dim]Cancelled.[/dim]")
579
+ return
580
+ else:
581
+ action = "add"
582
+
583
+ if action == "add":
584
+ _cost_add(copy)
585
+ elif action == "edit":
586
+ _cost_edit(copy)
587
+ elif action == "delete":
588
+ _cost_delete(copy)
589
+
590
+
591
+ # ---------------------------------------------------------------------------
592
+ # cmd_search
593
+ # ---------------------------------------------------------------------------
594
+
595
+ def cmd_search(args: list[str]) -> None:
596
+ """Search your collection with the full card grammar (same flags as `slab card search`)
597
+ plus collection-only filters (status, grading, acquisition, cost). Not a browser — for the
598
+ whole thing use `slab collection dashboard` (overview) or `slab collection export` (full list).
599
+
600
+ Examples:
601
+ slab collection search mcdavid
602
+ slab collection search --team "Minnesota Wild"
603
+ slab collection search --graded --company PSA
604
+ slab collection search --acq pack_pull --sort -fmv
605
+
606
+ Sort keys: acquired_date, acquisition_cost, numbered, year, card_number, brand, set,
607
+ fmv, unrealized, roi (prefix - for descending).
608
+ """
609
+ if not args:
610
+ total = ctx.client.search_collection(ctx.collector, CollectionSearchQuery(limit=1)).total
611
+ console.print(f"[bold]{total}[/bold] cards in your collection.")
612
+ console.print(" [dim]slab collection search <player>[/dim] find specific cards")
613
+ console.print(" [dim]slab collection dashboard[/dim] overview & stats")
614
+ console.print(" [dim]slab collection export[/dim] full printable list")
615
+ return
616
+ values, q = parse_flags(args, COLLECTION_SEARCH_FLAGS)
617
+ values.setdefault("limit", 25)
618
+ result = ctx.client.search_collection(ctx.collector, CollectionSearchQuery(q=q, **values))
619
+ print_collection(result)
620
+
621
+
622
+ cmd_search.flags = COLLECTION_SEARCH_FLAGS
623
+
624
+
625
+ def cmd_sell(args: list[str]) -> None:
626
+ """Record a sale — mark a copy as sold with the sale price."""
627
+ q = ask_text("Search your collection:")
628
+ result = ctx.client.search_collection(ctx.collector, CollectionSearchQuery(q=q, limit=25))
629
+ if not result.items:
630
+ console.print("[yellow]No matching cards in your collection.[/yellow]")
631
+ return
632
+
633
+ copy = select_copy(result.items, message=f"Select the copy to sell ({result.total} found):")
634
+ if copy is None:
635
+ return
636
+
637
+ sale_price = ask_decimal("Sale price:")
638
+ sold_date = ask_date("Sale date:")
639
+
640
+ console.print(f"\n [bold]Confirm sale:[/bold]")
641
+ card = copy.card
642
+ subjects = ", ".join(s.name for s in card.subjects) if card and card.subjects else "—"
643
+ console.print(f" Card: {card.card_number if card else '—'} {subjects}")
644
+ console.print(f" Sale price: ${sale_price:.2f}")
645
+ if copy.cost_basis is not None:
646
+ realized = sale_price - copy.cost_basis
647
+ color = "green" if realized >= 0 else "red"
648
+ sign = "+" if realized >= 0 else ""
649
+ console.print(f" Cost basis: ${copy.cost_basis:.2f}")
650
+ console.print(f" Realized: [{color}]{sign}${realized:,.2f}[/]")
651
+ console.print(f" Date: {sold_date}")
652
+
653
+ if not confirm("Record this sale?", default=True):
654
+ console.print("[dim]Cancelled.[/dim]")
655
+ return
656
+
657
+ updated = ctx.client.update_copy(
658
+ ctx.collector, copy.uuid,
659
+ CardCopyUpdate(
660
+ status=CopyStatus.sold,
661
+ sale_price=sale_price,
662
+ sold_date=sold_date,
663
+ ),
664
+ )
665
+ console.print(f"\n[green]Sold![/green] {card.card_number if card else '—'} {subjects}")
666
+ if updated.realized_gain_loss is not None:
667
+ g = updated.realized_gain_loss
668
+ color = "green" if g >= 0 else "red"
669
+ sign = "+" if g >= 0 else ""
670
+ console.print(f" Realized gain/loss: [{color}]{sign}${g:,.2f}[/]")
671
+ console.print()
672
+
673
+
674
+ def cmd_dashboard(args: list[str]) -> None:
675
+ """A visual overview of your collection — counts, distributions, breaks, and highlights."""
676
+ render_dashboard(ctx.client.get_dashboard(ctx.collector))