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