slab-cli 0.13.0__tar.gz → 0.16.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.13.0 → slab_cli-0.16.0}/PKG-INFO +2 -2
- {slab_cli-0.13.0 → slab_cli-0.16.0}/pyproject.toml +3 -2
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/browse.py +83 -28
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/client.py +12 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/catalog.py +20 -3
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/custom_sets.py +123 -24
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/registry.py +4 -3
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/display.py +77 -11
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/flags.py +6 -1
- {slab_cli-0.13.0 → slab_cli-0.16.0}/README.md +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/__init__.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/banner.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/__init__.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/breaks.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/collection.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/export.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/lots.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/pricing.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/setup.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/commands/update.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/config.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/context.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/help.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/main.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/paging.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/picker.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/platforms/__init__.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/platforms/base.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/platforms/posix.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/platforms/windows.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/prompts.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/sources.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/theme.py +0 -0
- {slab_cli-0.13.0 → slab_cli-0.16.0}/src/slab_cli/updates.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: slab-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.0
|
|
4
4
|
Summary: CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal.
|
|
5
5
|
Author: dev_jeb
|
|
6
|
-
Requires-Dist: slab-schemas>=0.
|
|
6
|
+
Requires-Dist: slab-schemas>=0.31.0
|
|
7
7
|
Requires-Dist: httpx>=0.27
|
|
8
8
|
Requires-Dist: rich>=13.0
|
|
9
9
|
Requires-Dist: inquirerpy>=0.3
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "slab-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.16.0"
|
|
4
4
|
description = "CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{name = "dev_jeb"}]
|
|
7
7
|
requires-python = ">=3.13"
|
|
8
8
|
dependencies = [
|
|
9
|
-
|
|
9
|
+
# 0.31: CardSearchQuery.collapse_parallels — `card search` sends it on every query.
|
|
10
|
+
"slab-schemas>=0.31.0",
|
|
10
11
|
"httpx>=0.27",
|
|
11
12
|
"rich>=13.0",
|
|
12
13
|
"InquirerPy>=0.3",
|
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
keyboard-driven viewport: ↑/↓ (or j/k) move the highlight, PgUp/PgDn jump, `/` type-to-filters
|
|
5
5
|
across every column, and q leaves — printing the plain table on the way out so the data
|
|
6
6
|
survives in scrollback. Enter drills down: with a `detail` callback the highlighted row's
|
|
7
|
-
detail view REPLACES the list as a scrollable page (Esc
|
|
7
|
+
detail view REPLACES the list as a scrollable page (Esc climbs back up to the list, same row
|
|
8
8
|
highlighted); without one, Enter simply returns the row's item to the caller. Anywhere output
|
|
9
9
|
isn't an interactive terminal (a pipe, CI, redirection) it degrades to the static table, so
|
|
10
10
|
`slab ... | grep` keeps working unchanged.
|
|
11
11
|
|
|
12
|
+
**A drill-down may be another list.** When `detail` returns a `Page` instead of printing, that
|
|
13
|
+
page is pushed as its own list screen with its own columns and its own `detail` — so a search can
|
|
14
|
+
show one row per card, its parallels one level down, and that printing's market view one level
|
|
15
|
+
below THAT, without any screen knowing how deep it sits. Esc pops exactly one level; q leaves the
|
|
16
|
+
whole stack at once (and prints the top-level table as the receipt).
|
|
17
|
+
|
|
12
18
|
Reading keys is the one OS-specific part, and this module doesn't do it: `platforms.current()`
|
|
13
19
|
hands back a backend whose `key_source()` yields a `read()` returning platform-neutral key NAMES
|
|
14
20
|
('up', 'enter', …). Everything below is written against those names, so it behaves identically on
|
|
@@ -21,6 +27,7 @@ the same spec drives the interactive view, the static fallback, and the exit rec
|
|
|
21
27
|
from __future__ import annotations
|
|
22
28
|
|
|
23
29
|
import sys
|
|
30
|
+
from dataclasses import dataclass, field
|
|
24
31
|
from typing import Any, Callable, Sequence
|
|
25
32
|
|
|
26
33
|
from rich.console import Group
|
|
@@ -33,6 +40,22 @@ from .theme import console, heading, slab_table
|
|
|
33
40
|
# One row: the domain object Enter should act on, plus its cell markup strings (one per column).
|
|
34
41
|
Row = tuple[Any, Sequence[str]]
|
|
35
42
|
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Page:
|
|
46
|
+
"""A list screen. Return one from a `detail` callback to drill from a list INTO another list
|
|
47
|
+
(instead of into a printed page) — same columns/rows/hint contract the root browse takes, so a
|
|
48
|
+
level is described the same way whether it's the first screen or the third."""
|
|
49
|
+
|
|
50
|
+
columns: list[tuple[str, dict]]
|
|
51
|
+
rows: list[Row]
|
|
52
|
+
title: str
|
|
53
|
+
note: str | None = None
|
|
54
|
+
hint: str = "open"
|
|
55
|
+
footer: str | None = None
|
|
56
|
+
detail: Callable[[Any], "Page | None"] | None = None
|
|
57
|
+
sel: int = field(default=0, repr=False) # where this level's highlight sits, kept across pops
|
|
58
|
+
|
|
36
59
|
# The highlight bar — a slate a step brighter than the card border, so the selected row reads as
|
|
37
60
|
# lit without fighting the cells' own foreground colors (markup styles set color only, never bg).
|
|
38
61
|
_SELECTED_BG = "on #223052"
|
|
@@ -149,9 +172,13 @@ def _run_list(
|
|
|
149
172
|
note: str | None,
|
|
150
173
|
hint: str,
|
|
151
174
|
start: int,
|
|
152
|
-
) -> tuple[Any | None, int]:
|
|
153
|
-
"""The list screen. Returns (chosen item, its index in `rows
|
|
154
|
-
|
|
175
|
+
) -> tuple[Any | None, int, str]:
|
|
176
|
+
"""The list screen. Returns (chosen item, its index in `rows`, action).
|
|
177
|
+
|
|
178
|
+
The action is "select" (Enter — item is set), "back" (Esc: up one level) or "quit" (q: leave
|
|
179
|
+
browsing entirely). The index is where the highlight ended up, so a drill-down returns to the
|
|
180
|
+
same row; Esc and q are different answers because with nested lists only one of them means
|
|
181
|
+
"I'm done", and collapsing them would strand a user three levels deep."""
|
|
155
182
|
plains = [row_plain(cells) for _item, cells in rows]
|
|
156
183
|
query, typing = "", False
|
|
157
184
|
sel = min(max(start, 0), len(rows) - 1)
|
|
@@ -198,14 +225,14 @@ def _run_list(
|
|
|
198
225
|
if typing:
|
|
199
226
|
typing = False # keep the filter, back to moving
|
|
200
227
|
elif view:
|
|
201
|
-
return view[sel][0], row_index(view[sel])
|
|
228
|
+
return view[sel][0], row_index(view[sel]), "select"
|
|
202
229
|
elif key == "esc":
|
|
203
230
|
if typing or query:
|
|
204
231
|
typing, query = False, ""
|
|
205
232
|
else:
|
|
206
|
-
return None, sel
|
|
233
|
+
return None, (row_index(view[sel]) if view else 0), "back"
|
|
207
234
|
elif key == "quit" or (not typing and key == "q"):
|
|
208
|
-
return None, (row_index(view[sel]) if view else 0)
|
|
235
|
+
return None, (row_index(view[sel]) if view else 0), "quit"
|
|
209
236
|
elif typing:
|
|
210
237
|
if key == "backspace":
|
|
211
238
|
if query:
|
|
@@ -260,11 +287,9 @@ def _run_pager(read: KeyReader, lines: list[Text], *, back_hint: str) -> None:
|
|
|
260
287
|
return
|
|
261
288
|
|
|
262
289
|
|
|
263
|
-
def
|
|
264
|
-
"""
|
|
265
|
-
|
|
266
|
-
render()
|
|
267
|
-
lines = [Text.from_ansi(line) for line in cap.get().splitlines()]
|
|
290
|
+
def _lines_from(captured: str) -> list[Text]:
|
|
291
|
+
"""Captured console output as trimmed styled lines — what the pager scrolls."""
|
|
292
|
+
lines = [Text.from_ansi(line) for line in captured.splitlines()]
|
|
268
293
|
while lines and not lines[-1].plain.strip():
|
|
269
294
|
lines.pop()
|
|
270
295
|
while lines and not lines[0].plain.strip():
|
|
@@ -272,6 +297,20 @@ def _capture_lines(render: Callable[[], None]) -> list[Text]:
|
|
|
272
297
|
return lines or [Text.from_markup("[slab.dim]Nothing to show.[/]")]
|
|
273
298
|
|
|
274
299
|
|
|
300
|
+
def _open(detail: Callable[[Any], "Page | None"], item: Any) -> tuple["Page | None", list[Text]]:
|
|
301
|
+
"""Run a drill-down and report which kind it was: `(page, lines)`.
|
|
302
|
+
|
|
303
|
+
A detail callback either returns a Page (another list) or prints its view and returns None, and
|
|
304
|
+
it can only say which by running — so the console is captured either way and the lines are kept
|
|
305
|
+
only for the printing kind. An empty Page falls back to its captured lines too, so a drill-down
|
|
306
|
+
that found nothing shows a page saying so rather than a dead list screen."""
|
|
307
|
+
with console.capture() as cap:
|
|
308
|
+
page = detail(item)
|
|
309
|
+
if page is not None and page.rows:
|
|
310
|
+
return page, []
|
|
311
|
+
return None, _lines_from(cap.get())
|
|
312
|
+
|
|
313
|
+
|
|
275
314
|
def browse(
|
|
276
315
|
columns: list[tuple[str, dict]],
|
|
277
316
|
rows: list[Row],
|
|
@@ -281,19 +320,23 @@ def browse(
|
|
|
281
320
|
hint: str = "open",
|
|
282
321
|
footer: str | None = None,
|
|
283
322
|
start: int = 0,
|
|
284
|
-
detail: Callable[[Any], None] | None = None,
|
|
323
|
+
detail: Callable[[Any], "Page | None"] | None = None,
|
|
285
324
|
) -> Any | None:
|
|
286
325
|
"""Browse `rows` interactively. `columns` is the same `(header, kwargs)` spec `slab_table`
|
|
287
326
|
takes; each row is `(item, cells)` with one markup string per column. `hint` names what
|
|
288
327
|
Enter does in the footer (e.g. "market view"); `start` pre-selects a row.
|
|
289
328
|
|
|
290
|
-
With a `detail` callback, Enter drills down
|
|
291
|
-
print to the console), its output replaces the list as a scrollable page, and leaving the
|
|
292
|
-
page returns to the list on the same row — the loop lives here, and browse returns None
|
|
293
|
-
when the user finally quits. Without `detail`, Enter returns the chosen item directly.
|
|
329
|
+
With a `detail` callback, Enter drills down and `detail(item)` decides into WHAT:
|
|
294
330
|
|
|
295
|
-
|
|
296
|
-
|
|
331
|
+
* it prints to the console and returns None — the output replaces the list as a scrollable
|
|
332
|
+
page (the leaf view: a market report, a break's card);
|
|
333
|
+
* it returns a `Page` — that page becomes another list screen, with its own `detail`.
|
|
334
|
+
|
|
335
|
+
Either way Esc climbs back one level, landing on the row it left from, and q leaves the whole
|
|
336
|
+
stack. Without a `detail`, Enter returns the chosen item to the caller instead.
|
|
337
|
+
|
|
338
|
+
Quitting prints the top-level table (with `footer`) as a scrollback receipt. On a
|
|
339
|
+
non-interactive stdout/stdin the static table is printed instead and None returned.
|
|
297
340
|
"""
|
|
298
341
|
if not rows:
|
|
299
342
|
return None
|
|
@@ -301,24 +344,36 @@ def browse(
|
|
|
301
344
|
print_table(columns, rows, title=title, note=note, footer=footer)
|
|
302
345
|
return None
|
|
303
346
|
|
|
347
|
+
root = Page(columns, rows, title, note, hint, footer, detail, sel=start)
|
|
304
348
|
picked: Any | None = None
|
|
305
|
-
sel = start
|
|
306
349
|
try:
|
|
307
350
|
with current_platform().key_source() as read:
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
351
|
+
stack = [root]
|
|
352
|
+
while stack:
|
|
353
|
+
page = stack[-1]
|
|
354
|
+
item, page.sel, action = _run_list(
|
|
355
|
+
read, page.columns, page.rows,
|
|
356
|
+
title=page.title, note=page.note, hint=page.hint, start=page.sel,
|
|
357
|
+
)
|
|
358
|
+
if action == "quit":
|
|
312
359
|
break
|
|
313
|
-
if
|
|
360
|
+
if action == "back":
|
|
361
|
+
stack.pop()
|
|
362
|
+
continue
|
|
363
|
+
if page.detail is None:
|
|
314
364
|
picked = item
|
|
315
365
|
break
|
|
316
|
-
|
|
366
|
+
drilled, lines = _open(page.detail, item)
|
|
367
|
+
if drilled is not None and drilled.rows:
|
|
368
|
+
stack.append(drilled)
|
|
369
|
+
else:
|
|
370
|
+
_run_pager(read, lines, back_hint="back to list")
|
|
317
371
|
except KeyboardInterrupt:
|
|
318
372
|
pass
|
|
319
373
|
|
|
320
374
|
if picked is not None:
|
|
321
375
|
return picked
|
|
322
|
-
# Quit: leave the classic table behind so the results live on in scrollback.
|
|
323
|
-
|
|
376
|
+
# Quit: leave the classic table behind so the results live on in scrollback. It's the TOP
|
|
377
|
+
# level's table however deep the user was — that's the list they asked for.
|
|
378
|
+
print_table(root.columns, root.rows, title=root.title, note=root.note, footer=root.footer)
|
|
324
379
|
return None
|
|
@@ -40,6 +40,7 @@ from slab_schemas.collection import (
|
|
|
40
40
|
from slab_schemas.custom_sets import (
|
|
41
41
|
CustomSetCardAdd,
|
|
42
42
|
CustomSetCardOut,
|
|
43
|
+
CustomSetCardUpdate,
|
|
43
44
|
CustomSetCreate,
|
|
44
45
|
CustomSetDetail,
|
|
45
46
|
CustomSetOut,
|
|
@@ -316,6 +317,17 @@ class SlabClient:
|
|
|
316
317
|
)
|
|
317
318
|
return CustomSetCardOut.model_validate(resp.json())
|
|
318
319
|
|
|
320
|
+
def update_set_card(
|
|
321
|
+
self, collector_uuid: str, set_uuid: str, entry_uuid: str, payload: CustomSetCardUpdate
|
|
322
|
+
) -> CustomSetCardOut:
|
|
323
|
+
# Sparse by fields-set (not exclude_none): explicitly passing None CLEARS a qualifier.
|
|
324
|
+
resp = self._request(
|
|
325
|
+
"PATCH",
|
|
326
|
+
f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/cards/{entry_uuid}",
|
|
327
|
+
json=payload.model_dump(mode="json", exclude_unset=True),
|
|
328
|
+
)
|
|
329
|
+
return CustomSetCardOut.model_validate(resp.json())
|
|
330
|
+
|
|
319
331
|
def delete_set_card(self, collector_uuid: str, set_uuid: str, entry_uuid: str) -> None:
|
|
320
332
|
self._request("DELETE", f"/collectors/{collector_uuid}/custom-sets/{set_uuid}/cards/{entry_uuid}")
|
|
321
333
|
|
|
@@ -34,25 +34,42 @@ def cmd_search(args: list[str]) -> None:
|
|
|
34
34
|
slab card search --brand "Upper Deck" --run-min 1 --run-max 99 # print-run range
|
|
35
35
|
slab card search McDavid --auto --parallel --sort -year # stack modifiers, newest first
|
|
36
36
|
slab card search --subset "Young Guns" --rookie --limit 50
|
|
37
|
+
slab card search Coffey --printings # every finish as its own row
|
|
37
38
|
|
|
38
39
|
Every flag combines with AND; repeat an any-of flag (--team, --brand, --finish…) to widen it.
|
|
39
40
|
|
|
40
41
|
Sort keys: year, card_number, numbered, subject, brand, set (prefix - for descending).
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
Results are one row per CARD, however many parallels it has — a player with a 17-finish
|
|
44
|
+
rainbow would otherwise bury his other cards under one of them. In a terminal that list is
|
|
45
|
+
scrollable and drills down: Enter opens the card's printings (base + every parallel, with
|
|
46
|
+
print run and odds), Enter again opens that printing's market view, Esc climbs back one level
|
|
47
|
+
and q leaves. `--printings` flattens it back to one row per printing (finish, run, and odds
|
|
48
|
+
on every row) — what you want when the finish IS the thing you're scanning for, and the form
|
|
49
|
+
to pipe when a script wants every parallel. Piped output prints the plain table of whichever
|
|
50
|
+
shape you asked for; the row shape doesn't change just because output is a terminal.
|
|
44
51
|
"""
|
|
45
52
|
if not args:
|
|
46
53
|
args = [ask_text("Search cards:")]
|
|
47
54
|
|
|
48
55
|
values, q = parse_flags(args, CARD_SEARCH_FLAGS)
|
|
49
56
|
values.setdefault("limit", 25)
|
|
57
|
+
values.setdefault("collapse_parallels", True)
|
|
50
58
|
result = ctx.client.search_cards(CardSearchQuery(q=q, **values))
|
|
51
59
|
|
|
52
60
|
def market_view(c) -> None:
|
|
53
61
|
render_card_market(ctx.client.get_card_market(c.uuid))
|
|
54
62
|
|
|
55
|
-
|
|
63
|
+
# The rainbow is fetched on drill-in, not up front: one call per card the user actually opens,
|
|
64
|
+
# and it returns the WHOLE slot even when the search matched a single finish.
|
|
65
|
+
def rainbow(c) -> list:
|
|
66
|
+
return ctx.client.get_parallels(c.uuid)
|
|
67
|
+
|
|
68
|
+
browse_cards(
|
|
69
|
+
result,
|
|
70
|
+
detail=market_view,
|
|
71
|
+
printings=rainbow if values["collapse_parallels"] else None,
|
|
72
|
+
)
|
|
56
73
|
|
|
57
74
|
|
|
58
75
|
cmd_search.flags = CARD_SEARCH_FLAGS
|
|
@@ -236,7 +236,8 @@ def _build_dynamic_filter() -> dict | None:
|
|
|
236
236
|
# ---------------------------------------------------------------------------
|
|
237
237
|
|
|
238
238
|
def cmd_chase_add(args: list[str]) -> None:
|
|
239
|
-
"""Add
|
|
239
|
+
"""Add entries to a curated custom set — specific cards or player slots, batch mode like
|
|
240
|
+
`slab collection add`."""
|
|
240
241
|
collector = ctx.collector
|
|
241
242
|
client = ctx.client
|
|
242
243
|
|
|
@@ -388,7 +389,7 @@ def cmd_chase_view(args: list[str]) -> None:
|
|
|
388
389
|
|
|
389
390
|
|
|
390
391
|
def cmd_chase_edit(args: list[str]) -> None:
|
|
391
|
-
"""Edit a custom set's name, description, or
|
|
392
|
+
"""Edit a custom set's name, description, visibility, or (dynamic) match granularity."""
|
|
392
393
|
collector = ctx.collector
|
|
393
394
|
client = ctx.client
|
|
394
395
|
|
|
@@ -404,7 +405,10 @@ def cmd_chase_edit(args: list[str]) -> None:
|
|
|
404
405
|
|
|
405
406
|
console.print(f"\n Current name: {cs.name}")
|
|
406
407
|
console.print(f" Current description: {cs.description or '—'}")
|
|
407
|
-
console.print(f" Current visibility: {cs.visibility}
|
|
408
|
+
console.print(f" Current visibility: {cs.visibility}")
|
|
409
|
+
if cs.set_type == "dynamic":
|
|
410
|
+
console.print(f" Current match granularity: {detail.dynamic_match}")
|
|
411
|
+
console.print()
|
|
408
412
|
|
|
409
413
|
from slab_schemas.custom_sets import CustomSetUpdate
|
|
410
414
|
|
|
@@ -413,11 +417,20 @@ def cmd_chase_edit(args: list[str]) -> None:
|
|
|
413
417
|
vis = None
|
|
414
418
|
if confirm("Change visibility?", default=False):
|
|
415
419
|
vis = select_visibility()
|
|
420
|
+
new_match = None
|
|
421
|
+
if cs.set_type == "dynamic" and confirm(
|
|
422
|
+
"Change what counts as having an entry? (per printing / per card / per player)",
|
|
423
|
+
default=False,
|
|
424
|
+
):
|
|
425
|
+
new_match = select_dynamic_match(default=detail.dynamic_match or "any_printing")
|
|
426
|
+
if new_match == detail.dynamic_match:
|
|
427
|
+
new_match = None
|
|
416
428
|
|
|
417
429
|
update = CustomSetUpdate(
|
|
418
430
|
name=name,
|
|
419
431
|
description=description,
|
|
420
432
|
visibility=Visibility(vis) if vis else None,
|
|
433
|
+
dynamic_match=DynamicMatch(new_match) if new_match else None,
|
|
421
434
|
)
|
|
422
435
|
|
|
423
436
|
# Only send if something changed
|
|
@@ -427,6 +440,11 @@ def cmd_chase_edit(args: list[str]) -> None:
|
|
|
427
440
|
|
|
428
441
|
updated = client.update_custom_set(collector, cs.uuid, update)
|
|
429
442
|
console.print(f"[green]Updated![/green] {updated.name}")
|
|
443
|
+
if new_match:
|
|
444
|
+
console.print(
|
|
445
|
+
f" [dim]Membership redefined — the set now tracks one entry per "
|
|
446
|
+
f"{'player' if new_match == 'any_card' else 'card slot' if new_match == 'any_printing' else 'printing'}.[/dim]"
|
|
447
|
+
)
|
|
430
448
|
|
|
431
449
|
|
|
432
450
|
# ---------------------------------------------------------------------------
|
|
@@ -521,33 +539,114 @@ def cmd_chase_remove(args: list[str]) -> None:
|
|
|
521
539
|
console.print("[yellow]Can only remove cards from curated sets.[/yellow]")
|
|
522
540
|
return
|
|
523
541
|
|
|
524
|
-
|
|
525
|
-
if
|
|
526
|
-
|
|
527
|
-
|
|
542
|
+
entry = _pick_entry(client, collector, cs.uuid, "Remove which entry?")
|
|
543
|
+
if entry and confirm(f"Remove this entry? ({entry.uuid[:8]})", default=False):
|
|
544
|
+
client.delete_set_card(collector, cs.uuid, entry.uuid)
|
|
545
|
+
console.print("[green]Removed.[/green]")
|
|
528
546
|
|
|
529
|
-
choices = []
|
|
530
|
-
for entry in detail.cards:
|
|
531
|
-
card = entry.card
|
|
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"
|
|
539
|
-
owned = " ✓" if entry.owned else ""
|
|
540
|
-
choices.append(Choice(value=entry, name=f"{label}{owned}"))
|
|
541
547
|
|
|
542
|
-
|
|
543
|
-
|
|
548
|
+
def _entry_label(entry) -> str:
|
|
549
|
+
"""One line describing an entry — the card and its mode, or the player slot + qualifiers."""
|
|
550
|
+
card = entry.card
|
|
551
|
+
if card:
|
|
552
|
+
subjects = ", ".join(s.name for s in card.subjects)
|
|
553
|
+
label = f"{card.card_number} {subjects} — {entry.match_mode}"
|
|
554
|
+
if entry.serial_number is not None:
|
|
555
|
+
label += f" #{entry.serial_number}"
|
|
556
|
+
return label
|
|
557
|
+
quals = " · ".join(str(q) for q in (
|
|
558
|
+
f"{entry.subject_year}-{str(entry.subject_year + 1)[-2:]}" if entry.subject_year else None,
|
|
559
|
+
entry.subject_team, entry.subject_attribute) if q)
|
|
560
|
+
return f"{entry.subject or '—'} — any card" + (f" · {quals}" if quals else "")
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _pick_entry(client: SlabClient, collector: str, set_uuid: str, message: str):
|
|
564
|
+
"""Fetch the full entry list and pick one (None if empty or backed out)."""
|
|
565
|
+
detail = _fetch_full_detail(client, set_uuid, collector)
|
|
566
|
+
if not detail.cards:
|
|
567
|
+
console.print("[dim]This set has no entries.[/dim]")
|
|
568
|
+
return None
|
|
569
|
+
choices = [
|
|
570
|
+
Choice(value=e, name=f"{_entry_label(e)}{' ✓' if e.owned else ''}")
|
|
571
|
+
for e in detail.cards
|
|
572
|
+
]
|
|
573
|
+
return inquirer.select(
|
|
574
|
+
message=f"{message} ({len(detail.cards)} in set):",
|
|
544
575
|
choices=choices,
|
|
545
576
|
max_height="70%",
|
|
546
577
|
).execute()
|
|
547
578
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
579
|
+
|
|
580
|
+
# ---------------------------------------------------------------------------
|
|
581
|
+
# slab chase tune — retune one entry (match mode / player-slot qualifiers)
|
|
582
|
+
# ---------------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
def cmd_chase_tune(args: list[str]) -> None:
|
|
585
|
+
"""Retune one entry of a curated set: a card entry's match mode, or a player slot's
|
|
586
|
+
season/team/attribute qualifiers. An entry's KIND (card vs player) is fixed — switch by
|
|
587
|
+
removing and re-adding."""
|
|
588
|
+
collector = ctx.collector
|
|
589
|
+
client = ctx.client
|
|
590
|
+
|
|
591
|
+
cs = _select_custom_set(client, collector, message="Which set to tune?")
|
|
592
|
+
if cs is None:
|
|
593
|
+
return
|
|
594
|
+
if cs.set_type != "curated":
|
|
595
|
+
console.print("[yellow]Dynamic sets have one set-wide granularity — use `slab chase edit`.[/yellow]")
|
|
596
|
+
return
|
|
597
|
+
detail = client.get_custom_set(cs.uuid, collector, limit=1)
|
|
598
|
+
if detail.creator_uuid != collector:
|
|
599
|
+
console.print("[yellow]You can only tune sets you created.[/yellow]")
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
entry = _pick_entry(client, collector, cs.uuid, "Tune which entry?")
|
|
603
|
+
if entry is None:
|
|
604
|
+
return
|
|
605
|
+
|
|
606
|
+
from slab_schemas.custom_sets import CustomSetCardUpdate
|
|
607
|
+
|
|
608
|
+
changes: dict = {}
|
|
609
|
+
if entry.card:
|
|
610
|
+
console.print(f"\n {_entry_label(entry)}")
|
|
611
|
+
new_mode = select_match_mode()
|
|
612
|
+
if new_mode != entry.match_mode:
|
|
613
|
+
changes["match_mode"] = MatchMode(new_mode)
|
|
614
|
+
if new_mode == "exact_serial":
|
|
615
|
+
serial = ask_int_optional("Which serial number?")
|
|
616
|
+
if serial is None:
|
|
617
|
+
console.print("[yellow]Serial number is required for exact_serial.[/yellow]")
|
|
618
|
+
return
|
|
619
|
+
if serial != entry.serial_number:
|
|
620
|
+
changes["serial_number"] = serial
|
|
621
|
+
else:
|
|
622
|
+
console.print(f"\n {_entry_label(entry)}")
|
|
623
|
+
console.print(" [dim]Retune the qualifiers — Enter keeps the current value, '-' clears it.[/dim]")
|
|
624
|
+
for field, label, cast in (
|
|
625
|
+
("subject_year", f"Season (current: {entry.subject_year or 'any'}):", int),
|
|
626
|
+
("subject_team", f"Team (current: {entry.subject_team or 'any'}):", str),
|
|
627
|
+
("subject_attribute", f"Attribute (current: {entry.subject_attribute or 'any'}):", str),
|
|
628
|
+
):
|
|
629
|
+
raw = ask_text_optional(label)
|
|
630
|
+
if not raw:
|
|
631
|
+
continue
|
|
632
|
+
if raw.strip() == "-":
|
|
633
|
+
if getattr(entry, field) is not None:
|
|
634
|
+
changes[field] = None
|
|
635
|
+
continue
|
|
636
|
+
try:
|
|
637
|
+
value = cast(raw.strip())
|
|
638
|
+
except ValueError:
|
|
639
|
+
console.print(f"[yellow] Skipped — not a valid value for {field}.[/yellow]")
|
|
640
|
+
continue
|
|
641
|
+
if value != getattr(entry, field):
|
|
642
|
+
changes[field] = value
|
|
643
|
+
|
|
644
|
+
if not changes:
|
|
645
|
+
console.print("[dim]No changes.[/dim]")
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
updated = client.update_set_card(collector, cs.uuid, entry.uuid, CustomSetCardUpdate(**changes))
|
|
649
|
+
console.print(f"[green]Tuned![/green] {_entry_label(updated)}")
|
|
551
650
|
|
|
552
651
|
|
|
553
652
|
# ---------------------------------------------------------------------------
|
|
@@ -94,13 +94,14 @@ GROUPS: list[Group] = [
|
|
|
94
94
|
]),
|
|
95
95
|
Group("chase", "custom sets & set-completion", [
|
|
96
96
|
Command("create", "Create a custom set (curated or dynamic)", custom_sets.cmd_chase_create),
|
|
97
|
-
Command("add", "Add cards to a curated set", custom_sets.cmd_chase_add),
|
|
97
|
+
Command("add", "Add cards or player slots to a curated set", custom_sets.cmd_chase_add),
|
|
98
98
|
Command("view", "View a set with completion + add missing", custom_sets.cmd_chase_view, "[uuid]"),
|
|
99
|
-
Command("edit", "Edit name, description, or
|
|
99
|
+
Command("edit", "Edit name, description, visibility, or granularity", custom_sets.cmd_chase_edit),
|
|
100
|
+
Command("tune", "Retune one entry — match mode or player-slot qualifiers", custom_sets.cmd_chase_tune),
|
|
100
101
|
Command("list", "Your sets (created + subscribed)", custom_sets.cmd_chase_list),
|
|
101
102
|
Command("find", "Discover public sets by name", custom_sets.cmd_chase_find, "[query]"),
|
|
102
103
|
Command("subscribe", "Subscribe to / unsubscribe from a public set", custom_sets.cmd_chase_subscribe, "[query]"),
|
|
103
|
-
Command("remove", "Remove
|
|
104
|
+
Command("remove", "Remove an entry from a set", custom_sets.cmd_chase_remove),
|
|
104
105
|
Command("delete", "Delete a set you created", custom_sets.cmd_chase_delete),
|
|
105
106
|
]),
|
|
106
107
|
Group("collector", "your identity", [
|
|
@@ -24,7 +24,7 @@ from slab_schemas.pricing import CardComps, CardMarket, PortfolioSummary, SetTop
|
|
|
24
24
|
from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
|
|
25
25
|
from slab_schemas.timeseries import CardPriceHistory
|
|
26
26
|
|
|
27
|
-
from .browse import browse
|
|
27
|
+
from .browse import Page, browse
|
|
28
28
|
from .theme import (
|
|
29
29
|
bar,
|
|
30
30
|
console,
|
|
@@ -102,7 +102,25 @@ CARD_COLUMNS = [
|
|
|
102
102
|
("Finish", {"style": "slab.accent"}),
|
|
103
103
|
("Attrs", {"style": "slab.dim"}),
|
|
104
104
|
("Run", {"justify": "right", "style": "slab.dim"}),
|
|
105
|
-
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
# The collapsed shape: one row per CARD, not per printing. Finish and Run are the two columns that
|
|
108
|
+
# vary *within* a slot, so they move down to the printings level and their place is taken by the
|
|
109
|
+
# count of printings there — the row says "this card exists 17 ways", the level below says which.
|
|
110
|
+
SLOT_COLUMNS = [
|
|
111
|
+
("Number", {"style": "slab.value"}),
|
|
112
|
+
("Subject(s)", {}),
|
|
113
|
+
("Set", {}),
|
|
114
|
+
("Subset", {}),
|
|
115
|
+
("Attrs", {"style": "slab.dim"}),
|
|
116
|
+
("Printings", {"justify": "right", "style": "slab.accent"}),
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
PRINTING_COLUMNS = [
|
|
120
|
+
("Finish", {"style": "slab.accent"}),
|
|
121
|
+
("Run", {"justify": "right"}),
|
|
122
|
+
("Attrs", {"style": "slab.dim"}),
|
|
123
|
+
("Odds", {"style": "slab.dim"}),
|
|
106
124
|
]
|
|
107
125
|
|
|
108
126
|
|
|
@@ -112,21 +130,69 @@ def card_cells(c: CardOut) -> list[str]:
|
|
|
112
130
|
attrs = ", ".join(a.name for a in c.attributes)
|
|
113
131
|
finish = c.finish or "[slab.dim]Base[/]"
|
|
114
132
|
pr = f"/{c.print_run}" if c.print_run else ""
|
|
115
|
-
return [c.card_number, subjects, c.set_name or "", c.subset or "", finish, attrs, pr
|
|
133
|
+
return [c.card_number, subjects, c.set_name or "", c.subset or "", finish, attrs, pr]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def slot_cells(c: CardOut) -> list[str]:
|
|
137
|
+
"""One card SLOT as its SLOT_COLUMNS cells — the base card plus how many printings it has."""
|
|
138
|
+
subjects = ", ".join(s.name for s in c.subjects)
|
|
139
|
+
attrs = ", ".join(a.name for a in c.attributes)
|
|
140
|
+
n = c.printing_count
|
|
141
|
+
# A card with no parallels is a dead end, so it reads as a dim 1 rather than an invitation.
|
|
142
|
+
printings = "[slab.dim]1[/]" if not n or n <= 1 else str(n)
|
|
143
|
+
return [c.card_number, subjects, c.set_name or "", c.subset or "", attrs, printings]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def printing_cells(c: CardOut) -> list[str]:
|
|
147
|
+
"""One printing of a slot as its PRINTING_COLUMNS cells — what differs between rainbow rows."""
|
|
148
|
+
attrs = ", ".join(a.name for a in c.attributes)
|
|
149
|
+
return [
|
|
150
|
+
c.finish or "[slab.dim]Base[/]",
|
|
151
|
+
f"/{c.print_run}" if c.print_run else "[slab.dim]—[/]",
|
|
152
|
+
attrs,
|
|
153
|
+
c.odds or "",
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def card_title(c: CardOut) -> str:
|
|
158
|
+
"""A single card named for a heading: '#86 Paul Coffey — 2021-22 Stature'."""
|
|
159
|
+
subjects = ", ".join(s.name for s in c.subjects) or "—"
|
|
160
|
+
return f"#{c.card_number} {subjects} — {c.set_name or ''} {c.subset or ''}".strip()
|
|
116
161
|
|
|
117
162
|
|
|
118
|
-
def browse_cards(result: CardSearchResult, *, detail=None) -> CardOut | None:
|
|
119
|
-
"""Browse a card search.
|
|
120
|
-
|
|
163
|
+
def browse_cards(result: CardSearchResult, *, detail=None, printings=None) -> CardOut | None:
|
|
164
|
+
"""Browse a card search. `detail` (a `CardOut -> None` renderer) is what Enter opens on a
|
|
165
|
+
PRINTING; Esc climbs back. Static table on non-interactive output.
|
|
166
|
+
|
|
167
|
+
With `printings` (a `CardOut -> list[CardOut]` loader, i.e. the parallels endpoint) the list is
|
|
168
|
+
one row per card and Enter opens that card's printings as a nested list, whose Enter runs
|
|
169
|
+
`detail` — so a search for a player reads as his cards, not as every finish of every card.
|
|
170
|
+
Without it, every printing is its own row and Enter runs `detail` straight away."""
|
|
121
171
|
if not result.items:
|
|
122
172
|
console.print("[slab.dim]No cards found.[/]")
|
|
123
173
|
return None
|
|
174
|
+
footer = _shown_footer(len(result.items), result.total,
|
|
175
|
+
"narrow your search, or `--limit N` to see more.")
|
|
176
|
+
if printings is None:
|
|
177
|
+
return browse(
|
|
178
|
+
CARD_COLUMNS, [(c, card_cells(c)) for c in result.items],
|
|
179
|
+
title="Cards", note=f"{result.total} total", hint="market view",
|
|
180
|
+
footer=footer, detail=detail,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def rainbow(card: CardOut) -> Page:
|
|
184
|
+
rows = printings(card)
|
|
185
|
+
return Page(
|
|
186
|
+
PRINTING_COLUMNS, [(p, printing_cells(p)) for p in rows],
|
|
187
|
+
title=card_title(card),
|
|
188
|
+
note=f"{len(rows)} printing{'' if len(rows) == 1 else 's'}",
|
|
189
|
+
hint="market view", detail=detail,
|
|
190
|
+
)
|
|
191
|
+
|
|
124
192
|
return browse(
|
|
125
|
-
|
|
126
|
-
title="Cards", note=f"{result.total} total", hint="
|
|
127
|
-
footer=
|
|
128
|
-
"narrow your search, or `--limit N` to see more."),
|
|
129
|
-
detail=detail,
|
|
193
|
+
SLOT_COLUMNS, [(c, slot_cells(c)) for c in result.items],
|
|
194
|
+
title="Cards", note=f"{result.total} total", hint="printings",
|
|
195
|
+
footer=footer, detail=rainbow,
|
|
130
196
|
)
|
|
131
197
|
|
|
132
198
|
|
|
@@ -162,7 +162,12 @@ _PAGING_FLAGS: list[Flag] = [
|
|
|
162
162
|
|
|
163
163
|
# What each search command parses: the shared card grammar + its scope's extras.
|
|
164
164
|
|
|
165
|
-
CARD_SEARCH_FLAGS: list[Flag] = CARD_FLAGS +
|
|
165
|
+
CARD_SEARCH_FLAGS: list[Flag] = CARD_FLAGS + [
|
|
166
|
+
# Catalog-only: mirrors CardSearchQuery.collapse_parallels, which CollectionSearchQuery has no
|
|
167
|
+
# equivalent of (a collection lists the copies you own, and a copy already IS one printing).
|
|
168
|
+
Flag("--printings", "collapse_parallels", "bool",
|
|
169
|
+
"One row per printing instead of per card", store=False),
|
|
170
|
+
] + _PAGING_FLAGS
|
|
166
171
|
|
|
167
172
|
COLLECTION_SEARCH_FLAGS: list[Flag] = CARD_FLAGS + [
|
|
168
173
|
# collection-only filters — mirrors CollectionSearchQuery's extension of CardFilter
|
|
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
|