archivedive 0.1.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.
- archivedive-0.1.0/.claude/settings.local.json +40 -0
- archivedive-0.1.0/.gitignore +9 -0
- archivedive-0.1.0/.python-version +1 -0
- archivedive-0.1.0/PKG-INFO +68 -0
- archivedive-0.1.0/README.md +55 -0
- archivedive-0.1.0/SEARCH_SYNTAX.md +201 -0
- archivedive-0.1.0/ga_archivedive/__init__.py +0 -0
- archivedive-0.1.0/ga_archivedive/api.py +301 -0
- archivedive-0.1.0/ga_archivedive/app.py +46 -0
- archivedive-0.1.0/ga_archivedive/models.py +167 -0
- archivedive-0.1.0/ga_archivedive/query.py +509 -0
- archivedive-0.1.0/ga_archivedive/screens/__init__.py +0 -0
- archivedive-0.1.0/ga_archivedive/screens/art_select.py +45 -0
- archivedive-0.1.0/ga_archivedive/screens/help.py +131 -0
- archivedive-0.1.0/ga_archivedive/screens/related.py +50 -0
- archivedive-0.1.0/ga_archivedive/screens/search.py +298 -0
- archivedive-0.1.0/ga_archivedive/widgets/__init__.py +0 -0
- archivedive-0.1.0/ga_archivedive/widgets/card_panel.py +270 -0
- archivedive-0.1.0/ga_archivedive/widgets/card_table.py +63 -0
- archivedive-0.1.0/pyproject.toml +30 -0
- archivedive-0.1.0/tests/__init__.py +0 -0
- archivedive-0.1.0/tests/test_api_client.py +154 -0
- archivedive-0.1.0/tests/test_cache.py +49 -0
- archivedive-0.1.0/tests/test_models.py +193 -0
- archivedive-0.1.0/tests/test_query.py +781 -0
- archivedive-0.1.0/uv.lock +421 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"WebSearch",
|
|
5
|
+
"WebFetch(domain:api-docs.gatcg.com)",
|
|
6
|
+
"WebFetch(domain:scryfall.com)",
|
|
7
|
+
"Bash(uv --version)",
|
|
8
|
+
"Bash(uv sync *)",
|
|
9
|
+
"Bash(git init *)",
|
|
10
|
+
"Skill(git-commit)",
|
|
11
|
+
"Bash(git add *)",
|
|
12
|
+
"Bash(git commit *)",
|
|
13
|
+
"Bash(uv run *)",
|
|
14
|
+
"Bash(git restore *)",
|
|
15
|
+
"Bash(uv add *)",
|
|
16
|
+
"Bash(uv remove *)",
|
|
17
|
+
"Bash(uv pip *)",
|
|
18
|
+
"WebFetch(domain:index.gatcg.com)",
|
|
19
|
+
"WebFetch(domain:rules.gatcg.com)",
|
|
20
|
+
"WebFetch(domain:www.gatcg.com)",
|
|
21
|
+
"Read(//home/celeryjro/Documents/Projects/ga-archivedive/ga_archivedive/**)",
|
|
22
|
+
"Read(//home/celeryjro/Documents/Projects/ga-archivedive/**)",
|
|
23
|
+
"Bash(python3 *)",
|
|
24
|
+
"Bash(.venv/bin/pytest *)",
|
|
25
|
+
"Bash(.venv/bin/python *)",
|
|
26
|
+
"Bash(curl -s \"https://api.gatcg.com/cards/search?subtype=CLERIC&per_page=3\")",
|
|
27
|
+
"Bash(curl -s \"https://api.gatcg.com/cards/search?subtype=MAGE&per_page=3\")",
|
|
28
|
+
"Bash(curl -s \"https://api.gatcg.com/cards/search?per_page=1\")",
|
|
29
|
+
"Bash(curl -s \"https://api.gatcg.com/cards/search?subtype=CLERIC&per_page=1\")",
|
|
30
|
+
"Bash(curl -s \"https://api.gatcg.com/cards/search?subtype=MAGE&per_page=1\")",
|
|
31
|
+
"Bash(python *)",
|
|
32
|
+
"WebFetch(domain:github.com)",
|
|
33
|
+
"WebFetch(domain:textual.textualize.io)",
|
|
34
|
+
"WebFetch(domain:raw.githubusercontent.com)",
|
|
35
|
+
"WebFetch(domain:darren.codes)",
|
|
36
|
+
"Bash(gh issue *)",
|
|
37
|
+
"WebFetch(domain:gitlab.gnome.org)"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: archivedive
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Scryfall-like TUI card browser for Grand Archive TCG
|
|
5
|
+
Author-email: Thi Dinh <vietthidinh2001@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: httpx>=0.27.0
|
|
9
|
+
Requires-Dist: platformdirs>=4.0
|
|
10
|
+
Requires-Dist: pydantic>=2.0
|
|
11
|
+
Requires-Dist: textual>=8.2.7
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# ArchiveDive
|
|
15
|
+
|
|
16
|
+
A terminal card browser for [Grand Archive TCG](https://www.gatcg.com/), inspired by [Scryfall](https://scryfall.com).
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
pip install archivedive
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
or with uv (no install needed):
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
uvx archivedive
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
archivedive
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Key bindings
|
|
39
|
+
|
|
40
|
+
| Key | Action |
|
|
41
|
+
| -------- | --------------------------- |
|
|
42
|
+
| `s` | Focus search bar |
|
|
43
|
+
| `F1` | Search syntax help |
|
|
44
|
+
| `c` | Copy card text to clipboard |
|
|
45
|
+
| `o` | Open card image in browser |
|
|
46
|
+
| `ctrl+o` | Select art edition |
|
|
47
|
+
| `r` | Show related cards |
|
|
48
|
+
| `ctrl+<` | Previous page |
|
|
49
|
+
| `ctrl+>` | Next page |
|
|
50
|
+
| `ctrl+c` | Copy search bar text |
|
|
51
|
+
| `ctrl+q` | Quit |
|
|
52
|
+
|
|
53
|
+
Clipboard copy requires `wl-clipboard` (Wayland), `xclip`, or `xsel` on Linux.
|
|
54
|
+
|
|
55
|
+
## Search syntax
|
|
56
|
+
|
|
57
|
+
Filters use `key:value` format and combine with `and`. Element filters use `or`.
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
silvie search by name
|
|
61
|
+
t:ally e:fire cost:2 fire ally costing 2
|
|
62
|
+
class:mage o:banish -r:common mage with banish, not common
|
|
63
|
+
e:fire or e:water fire or water element
|
|
64
|
+
t:champion legal:standard standard-legal champions
|
|
65
|
+
sort:rarity order:desc sort by rarity descending
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
See [SEARCH_SYNTAX.md](SEARCH_SYNTAX.md) for the full reference.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# ArchiveDive
|
|
2
|
+
|
|
3
|
+
A terminal card browser for [Grand Archive TCG](https://www.gatcg.com/), inspired by [Scryfall](https://scryfall.com).
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install archivedive
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
or with uv (no install needed):
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
uvx archivedive
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
archivedive
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Key bindings
|
|
26
|
+
|
|
27
|
+
| Key | Action |
|
|
28
|
+
| -------- | --------------------------- |
|
|
29
|
+
| `s` | Focus search bar |
|
|
30
|
+
| `F1` | Search syntax help |
|
|
31
|
+
| `c` | Copy card text to clipboard |
|
|
32
|
+
| `o` | Open card image in browser |
|
|
33
|
+
| `ctrl+o` | Select art edition |
|
|
34
|
+
| `r` | Show related cards |
|
|
35
|
+
| `ctrl+<` | Previous page |
|
|
36
|
+
| `ctrl+>` | Next page |
|
|
37
|
+
| `ctrl+c` | Copy search bar text |
|
|
38
|
+
| `ctrl+q` | Quit |
|
|
39
|
+
|
|
40
|
+
Clipboard copy requires `wl-clipboard` (Wayland), `xclip`, or `xsel` on Linux.
|
|
41
|
+
|
|
42
|
+
## Search syntax
|
|
43
|
+
|
|
44
|
+
Filters use `key:value` format and combine with `and`. Element filters use `or`.
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
silvie search by name
|
|
48
|
+
t:ally e:fire cost:2 fire ally costing 2
|
|
49
|
+
class:mage o:banish -r:common mage with banish, not common
|
|
50
|
+
e:fire or e:water fire or water element
|
|
51
|
+
t:champion legal:standard standard-legal champions
|
|
52
|
+
sort:rarity order:desc sort by rarity descending
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
See [SEARCH_SYNTAX.md](SEARCH_SYNTAX.md) for the full reference.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# ArchiveDive Search Syntax
|
|
2
|
+
|
|
3
|
+
## Basic search
|
|
4
|
+
|
|
5
|
+
Plain text searches by card name (fuzzy match).
|
|
6
|
+
|
|
7
|
+
silvie
|
|
8
|
+
dungeon guide
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Keyword filters
|
|
13
|
+
|
|
14
|
+
Filters use the format `key:value`. Multiple filters are combined with `and`.
|
|
15
|
+
|
|
16
|
+
| Key | Aliases | Description | Example |
|
|
17
|
+
| --------- | ------------------------- | ------------------------------- | ------------------ |
|
|
18
|
+
| `name:` | (plain text) | Card name | `silvie` |
|
|
19
|
+
| `o:` | `effect:` | Effect text (any edition) | `o:banish` |
|
|
20
|
+
| `oc:` | `oracle:` | Effect text (canonical only) | `oc:banish` |
|
|
21
|
+
| `kw:` | `keyword:` | Keyword ability (exact match) | `kw:stealth` |
|
|
22
|
+
| `rule:` | | Rule text (title or body) | `rule:graveyard` |
|
|
23
|
+
| `flavor:` | | Flavor text | `flavor:silvie` |
|
|
24
|
+
| `ill:` | `illustrator:` | Illustrator name (fuzzy) | `ill:dragonart` |
|
|
25
|
+
| `t:` | `type:` `sub:` `subtype:` | Type or subtype (searches both) | `t:ally` `t:human` |
|
|
26
|
+
| `class:` | `cl:` | Class | `class:mage` |
|
|
27
|
+
| `e:` | `element:` | Element (see below) | `e:fire` |
|
|
28
|
+
| `r:` | `rarity:` | Rarity (see below) | `r:rare` |
|
|
29
|
+
| `set:` | `s:` | Set prefix code | `set:DOA` |
|
|
30
|
+
| `cost:` | `c:` | Memory or reserve cost (either) | `cost:3` |
|
|
31
|
+
| `m:` | `memory:` | Memory cost only | `m:3` |
|
|
32
|
+
| `res:` | `reserve:` | Reserve cost only | `res:2` |
|
|
33
|
+
| `legal:` | | Legal in format | `legal:standard` |
|
|
34
|
+
| `banned:` | | Banned in format | `banned:standard` |
|
|
35
|
+
| `speed:` | | Speed: fast or slow | `speed:fast` |
|
|
36
|
+
| `is:` | | Flags (see below) | `is:material` |
|
|
37
|
+
| `pow:` | `power:` | Power | `pow:3` |
|
|
38
|
+
| `life:` | | Life | `life:4` |
|
|
39
|
+
| `dur:` | `durability:` | Durability | `dur:2` |
|
|
40
|
+
| `lvl:` | `level:` | Level | `lvl:2` |
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Flags (is:)
|
|
45
|
+
|
|
46
|
+
| Flag | Description | Expands to |
|
|
47
|
+
| -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
48
|
+
| `is:material` | Material deck cards | `t:champion or t:regalia` |
|
|
49
|
+
| `is:permanent` | Cards that stay on the field | `t:ally or t:champion or t:item or t:weapon or t:token or t:status or t:regalia or t:phantasia or t:mastery` |
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Sorting
|
|
54
|
+
|
|
55
|
+
Use `sort:` and `order:` to control result ordering.
|
|
56
|
+
|
|
57
|
+
| Key | Default | Values |
|
|
58
|
+
| -------- | ------- | ------------------------------------------------------------ |
|
|
59
|
+
| `sort:` | `name` | `name` `cost` `rarity` `power` `life` `level` `dur` `number` |
|
|
60
|
+
| `order:` | `asc` | `asc` `a` `desc` `d` |
|
|
61
|
+
|
|
62
|
+
Examples:
|
|
63
|
+
|
|
64
|
+
sort:rarity order:desc highest rarity first
|
|
65
|
+
sort:cost cheapest cards first
|
|
66
|
+
e:fire sort:power order:desc fire cards by power descending
|
|
67
|
+
t:champion sort:level champions sorted by level
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Quoting phrases
|
|
72
|
+
|
|
73
|
+
Wrap multi-word values in double quotes to search for an exact phrase.
|
|
74
|
+
Without quotes, spaces end the value and the rest is parsed as new tokens.
|
|
75
|
+
|
|
76
|
+
o:"on enter" effect contains the phrase "on enter"
|
|
77
|
+
o:"banish from memory" exact phrase in effect text
|
|
78
|
+
name:"silvie" works but unnecessary for single words
|
|
79
|
+
ill:dragonart illustrator name
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Operators (numeric fields only)
|
|
84
|
+
|
|
85
|
+
Supported: `=` (default), `>`, `<`, `>=`, `<=`
|
|
86
|
+
|
|
87
|
+
m>=3 memory cost 3 or more
|
|
88
|
+
pow<4 power less than 4
|
|
89
|
+
life=5 exactly 5 life
|
|
90
|
+
lvl<=2 level 2 or lower
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Negation
|
|
95
|
+
|
|
96
|
+
Prefix a filter with `-` to exclude it. Handled client-side — pagination
|
|
97
|
+
counts may differ slightly as results are filtered after fetching.
|
|
98
|
+
|
|
99
|
+
-e:fire not fire element
|
|
100
|
+
-t:champion not a champion
|
|
101
|
+
-r:common not common rarity
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## OR logic
|
|
106
|
+
|
|
107
|
+
Use `or` or `OR` between filters. Handled client-side via two API calls
|
|
108
|
+
merged and deduplicated.
|
|
109
|
+
|
|
110
|
+
e:fire or e:water fire or water
|
|
111
|
+
e:fire or e:water same thing
|
|
112
|
+
t:ally or t:champion ally or champion
|
|
113
|
+
o:banish or o:memory effect mentions banish or memory
|
|
114
|
+
|
|
115
|
+
`or` binds more loosely than adjacent filters:
|
|
116
|
+
|
|
117
|
+
t:ally e:fire or t:champion (ally AND fire) or (champion)
|
|
118
|
+
|
|
119
|
+
Use parentheses to make explicit grouping:
|
|
120
|
+
|
|
121
|
+
(t:ally e:fire) or (t:champion e:water) explicit grouping
|
|
122
|
+
(t:ally or t:champion) e:fire fire ally or fire champion
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Combining filters
|
|
127
|
+
|
|
128
|
+
t:ally e:fire cost:2 fire ally costing 2 (memory or reserve)
|
|
129
|
+
t:human class:mage o:banish human mage with banish in effect
|
|
130
|
+
t:champion -e:norm champion with any element
|
|
131
|
+
e:fire or e:water -r:common fire or water, excluding commons
|
|
132
|
+
set:DOA legal:standard Dawn of Ashes cards legal in standard
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Elements
|
|
137
|
+
|
|
138
|
+
fire water wind crux norm
|
|
139
|
+
arcane astra tera umbra luxem
|
|
140
|
+
neos exia exalted
|
|
141
|
+
|
|
142
|
+
Aliases: fi=fire wa=water wi=wind cr=crux no=norm
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Rarities
|
|
147
|
+
|
|
148
|
+
common (c) uncommon (u) rare (r)
|
|
149
|
+
superrare (sr) ultrarare (ur) promo (pr)
|
|
150
|
+
collectorsuper (csr) collectorultra (cur) collectorpromo (cpr)
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Legality formats
|
|
155
|
+
|
|
156
|
+
Used with `legal:` and `banned:`:
|
|
157
|
+
|
|
158
|
+
standard (s) pantheon (p) draft (d)
|
|
159
|
+
|
|
160
|
+
legal:standard legal:s
|
|
161
|
+
banned:pantheon banned:p
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Keywords (kw:)
|
|
166
|
+
|
|
167
|
+
Common keywords (59 total — see rules.gatcg.com/glossary/keywords-and-abilities):
|
|
168
|
+
|
|
169
|
+
stealth taunt steadfast unblockable true sight
|
|
170
|
+
intercept cleave agility ambush on enter
|
|
171
|
+
on death on hit on attack on kill floating memory
|
|
172
|
+
vigor ranged empower bulwark immortality
|
|
173
|
+
|
|
174
|
+
`kw:` matches only cards that have the keyword — not cards that merely mention
|
|
175
|
+
it in their text. Handled client-side: fetches by effect text, then filters
|
|
176
|
+
to cards where the keyword appears as a standalone ability.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Examples
|
|
181
|
+
|
|
182
|
+
silvie
|
|
183
|
+
t:ally e:fire cost:2
|
|
184
|
+
class:mage o:banish -r:common
|
|
185
|
+
t:champion legal:p lvl<=3
|
|
186
|
+
pow>=3 life>=3 t:human
|
|
187
|
+
o:memory speed:fast
|
|
188
|
+
set:DOA t:champion
|
|
189
|
+
banned:standard
|
|
190
|
+
legal:pantheon t:champion
|
|
191
|
+
is:material e:fire
|
|
192
|
+
is:permanent -t:champion
|
|
193
|
+
e:fire or e:water -r:common -r:uncommon
|
|
194
|
+
o:"on enter" t:ally class:mage
|
|
195
|
+
oc:"banish from memory"
|
|
196
|
+
kw:stealth t:ally
|
|
197
|
+
kw:taunt or kw:intercept
|
|
198
|
+
rule:graveyard
|
|
199
|
+
flavor:"courage"
|
|
200
|
+
ill:dragonart r:csr
|
|
201
|
+
is:material legal:s -r:common
|
|
File without changes
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from platformdirs import user_cache_dir
|
|
11
|
+
|
|
12
|
+
from .models import Card, SearchResponse
|
|
13
|
+
|
|
14
|
+
BASE_URL = "https://api.gatcg.com"
|
|
15
|
+
CACHE_TTL = 3600 # 1 hour
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _cache_path() -> Path:
|
|
19
|
+
path = Path(user_cache_dir("ga-archivedive")) / "cache.db"
|
|
20
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
return path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Cache:
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._db = sqlite3.connect(_cache_path())
|
|
27
|
+
self._db.execute(
|
|
28
|
+
"CREATE TABLE IF NOT EXISTS cache "
|
|
29
|
+
"(key TEXT PRIMARY KEY, value TEXT, expires_at REAL)"
|
|
30
|
+
)
|
|
31
|
+
self._db.commit()
|
|
32
|
+
|
|
33
|
+
def get(self, key: str) -> Any | None:
|
|
34
|
+
row = self._db.execute(
|
|
35
|
+
"SELECT value, expires_at FROM cache WHERE key = ?", (key,)
|
|
36
|
+
).fetchone()
|
|
37
|
+
if row is None:
|
|
38
|
+
return None
|
|
39
|
+
value, expires_at = row
|
|
40
|
+
if time.time() > expires_at:
|
|
41
|
+
self._db.execute("DELETE FROM cache WHERE key = ?", (key,))
|
|
42
|
+
self._db.commit()
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
return json.loads(value)
|
|
46
|
+
except json.JSONDecodeError:
|
|
47
|
+
self._db.execute("DELETE FROM cache WHERE key = ?", (key,))
|
|
48
|
+
self._db.commit()
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def set(self, key: str, value: Any, ttl: int = CACHE_TTL) -> None:
|
|
52
|
+
self._db.execute(
|
|
53
|
+
"INSERT OR REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)",
|
|
54
|
+
(key, json.dumps(value), time.time() + ttl),
|
|
55
|
+
)
|
|
56
|
+
self._db.commit()
|
|
57
|
+
|
|
58
|
+
def clear(self) -> None:
|
|
59
|
+
self._db.execute("DELETE FROM cache")
|
|
60
|
+
self._db.commit()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _or_sort_key(card: Card, sort: str) -> tuple:
|
|
64
|
+
name = (card.name or "").lower()
|
|
65
|
+
if sort == "name":
|
|
66
|
+
return (name,)
|
|
67
|
+
if sort == "power":
|
|
68
|
+
return (card.power or 0, name)
|
|
69
|
+
if sort == "life":
|
|
70
|
+
return (card.life or 0, name)
|
|
71
|
+
if sort == "level":
|
|
72
|
+
return (card.level or 0, name)
|
|
73
|
+
if sort == "durability":
|
|
74
|
+
return (card.durability or 0, name)
|
|
75
|
+
if sort in ("cost_memory", "cost_reserve"):
|
|
76
|
+
try:
|
|
77
|
+
v = int((card.cost and card.cost.value) or 0)
|
|
78
|
+
except (ValueError, TypeError):
|
|
79
|
+
v = 0
|
|
80
|
+
return (v, name)
|
|
81
|
+
if sort == "rarity":
|
|
82
|
+
eds = card.result_editions or card.editions
|
|
83
|
+
try:
|
|
84
|
+
r = int(eds[0].rarity) if eds and eds[0].rarity else 0
|
|
85
|
+
except (ValueError, TypeError):
|
|
86
|
+
r = 0
|
|
87
|
+
return (r, name)
|
|
88
|
+
return (name,)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class GAClient:
|
|
92
|
+
def __init__(self) -> None:
|
|
93
|
+
self._http = httpx.AsyncClient(
|
|
94
|
+
base_url=BASE_URL,
|
|
95
|
+
timeout=10.0,
|
|
96
|
+
headers={"Accept": "application/json"},
|
|
97
|
+
)
|
|
98
|
+
self._cache = _Cache()
|
|
99
|
+
|
|
100
|
+
async def close(self) -> None:
|
|
101
|
+
await self._http.aclose()
|
|
102
|
+
|
|
103
|
+
async def search(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
name: str | None = None,
|
|
107
|
+
element: list[str] | None = None,
|
|
108
|
+
type: list[str] | None = None,
|
|
109
|
+
subtype: list[str] | None = None,
|
|
110
|
+
cls: list[str] | None = None,
|
|
111
|
+
rarity: list[str] | None = None,
|
|
112
|
+
cost_memory: int | None = None,
|
|
113
|
+
cost_reserve: int | None = None,
|
|
114
|
+
effect: str | None = None,
|
|
115
|
+
legality_format: str | None = None,
|
|
116
|
+
sort: str = "name",
|
|
117
|
+
order: str = "ASC",
|
|
118
|
+
page: int = 1,
|
|
119
|
+
page_size: int = 50,
|
|
120
|
+
) -> SearchResponse:
|
|
121
|
+
params: dict[str, Any] = {
|
|
122
|
+
"sort": sort,
|
|
123
|
+
"order": order,
|
|
124
|
+
"page": page,
|
|
125
|
+
"page_size": page_size,
|
|
126
|
+
}
|
|
127
|
+
if name:
|
|
128
|
+
params["name"] = name
|
|
129
|
+
if element:
|
|
130
|
+
params["element[]"] = element
|
|
131
|
+
if type:
|
|
132
|
+
params["type[]"] = type
|
|
133
|
+
if subtype:
|
|
134
|
+
params["subtype[]"] = subtype
|
|
135
|
+
if cls:
|
|
136
|
+
params["class[]"] = cls
|
|
137
|
+
if rarity:
|
|
138
|
+
params["rarity[]"] = rarity
|
|
139
|
+
if cost_memory is not None:
|
|
140
|
+
params["cost_memory"] = cost_memory
|
|
141
|
+
if cost_reserve is not None:
|
|
142
|
+
params["cost_reserve"] = cost_reserve
|
|
143
|
+
if effect:
|
|
144
|
+
params["effect"] = effect
|
|
145
|
+
if legality_format:
|
|
146
|
+
params["legality_format"] = legality_format
|
|
147
|
+
|
|
148
|
+
cache_key = f"search:{json.dumps(params, sort_keys=True)}"
|
|
149
|
+
if cached := self._cache.get(cache_key):
|
|
150
|
+
return SearchResponse.model_validate(cached)
|
|
151
|
+
|
|
152
|
+
response = await self._http.get("/cards/search", params=params)
|
|
153
|
+
response.raise_for_status()
|
|
154
|
+
data = response.json()
|
|
155
|
+
self._cache.set(cache_key, data)
|
|
156
|
+
return SearchResponse.model_validate(data)
|
|
157
|
+
|
|
158
|
+
async def get_card(self, slug: str) -> Card:
|
|
159
|
+
cache_key = f"card:{slug}"
|
|
160
|
+
if cached := self._cache.get(cache_key):
|
|
161
|
+
return Card.model_validate(cached)
|
|
162
|
+
|
|
163
|
+
response = await self._http.get(f"/cards/{slug}")
|
|
164
|
+
response.raise_for_status()
|
|
165
|
+
data = response.json()
|
|
166
|
+
self._cache.set(cache_key, data)
|
|
167
|
+
return Card.model_validate(data)
|
|
168
|
+
|
|
169
|
+
async def autocomplete(self, name: str) -> list[Card]:
|
|
170
|
+
cache_key = f"autocomplete:{name}"
|
|
171
|
+
if cached := self._cache.get(cache_key):
|
|
172
|
+
return [Card.model_validate(c) for c in cached]
|
|
173
|
+
|
|
174
|
+
response = await self._http.get("/cards/autocomplete", params={"name": name})
|
|
175
|
+
response.raise_for_status()
|
|
176
|
+
data = response.json()
|
|
177
|
+
results = data.get("data", data) if isinstance(data, dict) else data
|
|
178
|
+
self._cache.set(cache_key, results, ttl=300)
|
|
179
|
+
return [Card.model_validate(c) for c in results]
|
|
180
|
+
|
|
181
|
+
async def random(self, count: int = 8) -> list[Card]:
|
|
182
|
+
response = await self._http.get("/cards/random", params={"count": count})
|
|
183
|
+
response.raise_for_status()
|
|
184
|
+
data = response.json()
|
|
185
|
+
cards = data.get("data", data) if isinstance(data, dict) else data
|
|
186
|
+
return [Card.model_validate(c) for c in cards]
|
|
187
|
+
|
|
188
|
+
async def search_query(
|
|
189
|
+
self,
|
|
190
|
+
query: str,
|
|
191
|
+
page: int = 1,
|
|
192
|
+
page_size: int = 50,
|
|
193
|
+
) -> SearchResponse:
|
|
194
|
+
from .query import parse, to_api_params, apply_client_filters
|
|
195
|
+
|
|
196
|
+
parsed = parse(query)
|
|
197
|
+
|
|
198
|
+
if not parsed.groups or all(not g for g in parsed.groups):
|
|
199
|
+
if parsed.warnings:
|
|
200
|
+
return SearchResponse(
|
|
201
|
+
data=[], total_cards=0, total_pages=1, has_more=False,
|
|
202
|
+
paginated_cards_count=0, page=page, page_size=page_size,
|
|
203
|
+
)
|
|
204
|
+
return await self.search(page=page, page_size=page_size)
|
|
205
|
+
|
|
206
|
+
if len(parsed.groups) == 1:
|
|
207
|
+
return await self._fetch_group(
|
|
208
|
+
parsed.groups[0], page, page_size,
|
|
209
|
+
sort=parsed.sort, order=parsed.order,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# OR: fetch each group and merge, deduplicated by slug
|
|
213
|
+
seen: set[str] = set()
|
|
214
|
+
merged: list[Card] = []
|
|
215
|
+
for group in parsed.groups:
|
|
216
|
+
result = await self._fetch_group(
|
|
217
|
+
group, page=1, page_size=page_size,
|
|
218
|
+
sort=parsed.sort, order=parsed.order,
|
|
219
|
+
)
|
|
220
|
+
for card in result.data:
|
|
221
|
+
if card.slug not in seen:
|
|
222
|
+
seen.add(card.slug)
|
|
223
|
+
merged.append(card)
|
|
224
|
+
|
|
225
|
+
merged.sort(
|
|
226
|
+
key=lambda c: _or_sort_key(c, parsed.sort),
|
|
227
|
+
reverse=(parsed.order.upper() == "DESC"),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
resp = SearchResponse(
|
|
231
|
+
data=merged,
|
|
232
|
+
total_cards=len(merged),
|
|
233
|
+
total_pages=1,
|
|
234
|
+
has_more=False,
|
|
235
|
+
paginated_cards_count=len(merged),
|
|
236
|
+
page=1,
|
|
237
|
+
page_size=page_size,
|
|
238
|
+
)
|
|
239
|
+
return resp
|
|
240
|
+
|
|
241
|
+
async def _fetch_group(
|
|
242
|
+
self,
|
|
243
|
+
filters: list,
|
|
244
|
+
page: int,
|
|
245
|
+
page_size: int,
|
|
246
|
+
sort: str = "name",
|
|
247
|
+
order: str = "ASC",
|
|
248
|
+
) -> SearchResponse:
|
|
249
|
+
from .query import to_api_params, apply_client_filters
|
|
250
|
+
|
|
251
|
+
params = to_api_params(filters)
|
|
252
|
+
params["sort"] = sort
|
|
253
|
+
params["order"] = order
|
|
254
|
+
params["page"] = page
|
|
255
|
+
params["page_size"] = page_size
|
|
256
|
+
|
|
257
|
+
cache_key = f"query:{json.dumps(params, sort_keys=True, default=str)}"
|
|
258
|
+
if cached := self._cache.get(cache_key):
|
|
259
|
+
result = SearchResponse.model_validate(cached)
|
|
260
|
+
else:
|
|
261
|
+
# Build httpx-compatible param list (multi-value support)
|
|
262
|
+
param_list: list[tuple[str, Any]] = []
|
|
263
|
+
for k, v in params.items():
|
|
264
|
+
if isinstance(v, list):
|
|
265
|
+
for item in v:
|
|
266
|
+
param_list.append((k, item))
|
|
267
|
+
else:
|
|
268
|
+
param_list.append((k, v))
|
|
269
|
+
|
|
270
|
+
response = await self._http.get("/cards/search", params=param_list)
|
|
271
|
+
response.raise_for_status()
|
|
272
|
+
data = response.json()
|
|
273
|
+
self._cache.set(cache_key, data)
|
|
274
|
+
result = SearchResponse.model_validate(data)
|
|
275
|
+
|
|
276
|
+
result.data = apply_client_filters(result.data, filters)
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
async def fetch_known_types(self) -> set[str]:
|
|
280
|
+
"""Fetch all valid card types from the API definitions endpoint."""
|
|
281
|
+
cache_key = "definitions:types"
|
|
282
|
+
if cached := self._cache.get(cache_key):
|
|
283
|
+
return set(cached)
|
|
284
|
+
try:
|
|
285
|
+
response = await self._http.get("/option/search")
|
|
286
|
+
response.raise_for_status()
|
|
287
|
+
types = {entry["value"] for entry in response.json().get("type", [])}
|
|
288
|
+
self._cache.set(cache_key, list(types), ttl=86400) # cache 24h
|
|
289
|
+
return types
|
|
290
|
+
except Exception:
|
|
291
|
+
return set()
|
|
292
|
+
|
|
293
|
+
def image_url(self, filename: str) -> str:
|
|
294
|
+
return f"{BASE_URL}/cards/images/{filename}"
|
|
295
|
+
|
|
296
|
+
async def fetch_image(self, filename: str) -> bytes:
|
|
297
|
+
if filename.startswith("/cards/images/"):
|
|
298
|
+
filename = filename[len("/cards/images/"):]
|
|
299
|
+
response = await self._http.get(f"/cards/images/{filename}")
|
|
300
|
+
response.raise_for_status()
|
|
301
|
+
return response.content
|