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,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))
|