ptg-editor 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.
- ptg_editor-0.1.0/PKG-INFO +9 -0
- ptg_editor-0.1.0/pyproject.toml +29 -0
- ptg_editor-0.1.0/setup.cfg +4 -0
- ptg_editor-0.1.0/src/ptg_editor/__init__.py +0 -0
- ptg_editor-0.1.0/src/ptg_editor/app.py +88 -0
- ptg_editor-0.1.0/src/ptg_editor/config.py +68 -0
- ptg_editor-0.1.0/src/ptg_editor/main.py +8 -0
- ptg_editor-0.1.0/src/ptg_editor/routes/__init__.py +0 -0
- ptg_editor-0.1.0/src/ptg_editor/routes/api.py +395 -0
- ptg_editor-0.1.0/src/ptg_editor/routes/views.py +186 -0
- ptg_editor-0.1.0/src/ptg_editor/services.py +324 -0
- ptg_editor-0.1.0/src/ptg_editor/set_manager.py +332 -0
- ptg_editor-0.1.0/src/ptg_editor/static/css/style.css +293 -0
- ptg_editor-0.1.0/src/ptg_editor/static/js/card-editor.js +397 -0
- ptg_editor-0.1.0/src/ptg_editor/static/js/deck-builder.js +286 -0
- ptg_editor-0.1.0/src/ptg_editor/templates/base.html +39 -0
- ptg_editor-0.1.0/src/ptg_editor/templates/card-browser.html +60 -0
- ptg_editor-0.1.0/src/ptg_editor/templates/card-editor.html +336 -0
- ptg_editor-0.1.0/src/ptg_editor/templates/deck-builder.html +126 -0
- ptg_editor-0.1.0/src/ptg_editor/templates/index.html +117 -0
- ptg_editor-0.1.0/src/ptg_editor/templates/macros/mana_table.html +14 -0
- ptg_editor-0.1.0/src/ptg_editor/templates/manifest-editor.html +104 -0
- ptg_editor-0.1.0/src/ptg_editor.egg-info/PKG-INFO +9 -0
- ptg_editor-0.1.0/src/ptg_editor.egg-info/SOURCES.txt +29 -0
- ptg_editor-0.1.0/src/ptg_editor.egg-info/dependency_links.txt +1 -0
- ptg_editor-0.1.0/src/ptg_editor.egg-info/entry_points.txt +2 -0
- ptg_editor-0.1.0/src/ptg_editor.egg-info/requires.txt +3 -0
- ptg_editor-0.1.0/src/ptg_editor.egg-info/top_level.txt +1 -0
- ptg_editor-0.1.0/tests/test_routes.py +380 -0
- ptg_editor-0.1.0/tests/test_services.py +296 -0
- ptg_editor-0.1.0/tests/test_set_manager.py +175 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ptg-editor"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Web-based editor for PTG card game sets"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
authors = [
|
|
7
|
+
{name = "Daniel Pérez Rodríguez"}
|
|
8
|
+
]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"ptg>=0.1.19",
|
|
11
|
+
"flask>=3.0",
|
|
12
|
+
"pyyaml>=6.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
ptg-editor = "ptg_editor.main:main"
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["setuptools>=68.0"]
|
|
20
|
+
build-backend = "setuptools.build_meta"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.package-data]
|
|
26
|
+
ptg_editor = [
|
|
27
|
+
"templates/**/*",
|
|
28
|
+
"static/**/*",
|
|
29
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Flask application factory and entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from flask import Flask
|
|
10
|
+
from markupsafe import Markup
|
|
11
|
+
|
|
12
|
+
from ptg_editor.config import load_config, get_base_dir
|
|
13
|
+
|
|
14
|
+
MANA_LABEL_MAP = {
|
|
15
|
+
"F": "fire", "E": "earth", "W": "water", "A": "air", "Y": "any",
|
|
16
|
+
"fire": "fire", "earth": "earth", "water": "water", "air": "air", "any": "any",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _mana_to_badges_html(mana) -> str:
|
|
21
|
+
"""Convert mana dict or string like 'F2 A1' into HTML badge spans."""
|
|
22
|
+
if isinstance(mana, dict):
|
|
23
|
+
parts = []
|
|
24
|
+
for key, val in mana.items():
|
|
25
|
+
key_str = key.value if hasattr(key, 'value') else str(key)
|
|
26
|
+
if val and val > 0:
|
|
27
|
+
label = "Y" if key_str == "any" else key_str[0].upper()
|
|
28
|
+
parts.append(f"{label}{val}")
|
|
29
|
+
elif isinstance(mana, str) and mana.strip():
|
|
30
|
+
parts = mana.strip().split()
|
|
31
|
+
else:
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
badges = []
|
|
35
|
+
for part in parts:
|
|
36
|
+
m = re.match(r"^([A-Za-z])(\d+)$", part)
|
|
37
|
+
if not m:
|
|
38
|
+
continue
|
|
39
|
+
label, amount = m.group(1).upper(), m.group(2)
|
|
40
|
+
mana_key = MANA_LABEL_MAP.get(label, "any")
|
|
41
|
+
badges.append(
|
|
42
|
+
f'<span class="mana-badge mana-badge-{mana_key}">'
|
|
43
|
+
f'{label}{amount}'
|
|
44
|
+
f'</span>'
|
|
45
|
+
)
|
|
46
|
+
return " ".join(badges)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_app(config: dict | None = None) -> Flask:
|
|
50
|
+
if config is None:
|
|
51
|
+
config = load_config()
|
|
52
|
+
|
|
53
|
+
app = Flask(__name__)
|
|
54
|
+
app.secret_key = "ptg-editor-session-key"
|
|
55
|
+
app.config["PTG_CONFIG"] = config
|
|
56
|
+
app.config["PTG_BASE_DIR"] = get_base_dir(config)
|
|
57
|
+
app.config["PTG_BASE_DIR"].mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
app.jinja_env.globals["hasattr"] = hasattr
|
|
60
|
+
|
|
61
|
+
@app.template_filter("mana_badges")
|
|
62
|
+
def mana_badges(mana) -> str:
|
|
63
|
+
return Markup(_mana_to_badges_html(mana))
|
|
64
|
+
|
|
65
|
+
from ptg_editor.routes.views import views_bp
|
|
66
|
+
from ptg_editor.routes.api import api_bp
|
|
67
|
+
|
|
68
|
+
app.register_blueprint(views_bp)
|
|
69
|
+
app.register_blueprint(api_bp, url_prefix="/api")
|
|
70
|
+
|
|
71
|
+
return app
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> None:
|
|
75
|
+
config = load_config()
|
|
76
|
+
app = create_app(config)
|
|
77
|
+
server = config.get("server", {})
|
|
78
|
+
host = server.get("host", "0.0.0.0")
|
|
79
|
+
port = server.get("port", 5000)
|
|
80
|
+
debug = config.get("editor", {}).get("debug", False)
|
|
81
|
+
|
|
82
|
+
print(f"PTG Editor server starting on http://{host}:{port}")
|
|
83
|
+
print(f"Sets directory: {app.config['PTG_BASE_DIR']}")
|
|
84
|
+
app.run(host=host, port=port, debug=debug)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Application configuration loader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
13
|
+
"server": {
|
|
14
|
+
"host": "0.0.0.0",
|
|
15
|
+
"port": 5000,
|
|
16
|
+
},
|
|
17
|
+
"sets": {
|
|
18
|
+
"base_dir": "",
|
|
19
|
+
},
|
|
20
|
+
"editor": {
|
|
21
|
+
"debug": False,
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _find_config_path() -> Path | None:
|
|
27
|
+
env_path = os.environ.get("PTG_EDITOR_CONFIG")
|
|
28
|
+
if env_path:
|
|
29
|
+
p = Path(env_path)
|
|
30
|
+
if p.exists():
|
|
31
|
+
return p
|
|
32
|
+
|
|
33
|
+
cwd_path = Path.cwd() / "config.yaml"
|
|
34
|
+
if cwd_path.exists():
|
|
35
|
+
return cwd_path
|
|
36
|
+
|
|
37
|
+
home_path = Path.home() / ".config" / "ptg-editor" / "config.yaml"
|
|
38
|
+
if home_path.exists():
|
|
39
|
+
return home_path
|
|
40
|
+
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
45
|
+
result = dict(base)
|
|
46
|
+
for key, value in override.items():
|
|
47
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
48
|
+
result[key] = _deep_merge(result[key], value)
|
|
49
|
+
else:
|
|
50
|
+
result[key] = value
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_config() -> dict[str, Any]:
|
|
55
|
+
config = dict(DEFAULT_CONFIG)
|
|
56
|
+
path = _find_config_path()
|
|
57
|
+
if path:
|
|
58
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
59
|
+
user_config = yaml.safe_load(f) or {}
|
|
60
|
+
config = _deep_merge(config, user_config)
|
|
61
|
+
return config
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_base_dir(config: dict[str, Any]) -> Path:
|
|
65
|
+
base = config.get("sets", {}).get("base_dir", "")
|
|
66
|
+
if base:
|
|
67
|
+
return Path(base).resolve()
|
|
68
|
+
return (Path.cwd() / "sets").resolve()
|
|
File without changes
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""JSON API routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from flask import Blueprint, request, jsonify, current_app, send_from_directory
|
|
10
|
+
|
|
11
|
+
from ptg_editor.set_manager import SetManager
|
|
12
|
+
from ptg_editor.services import (
|
|
13
|
+
card_to_yaml, validate_card_yaml, save_card_yaml, load_card_from_file,
|
|
14
|
+
yaml_to_form_data, list_effects,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
api_bp = Blueprint("api", __name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_base_dir() -> Path:
|
|
21
|
+
return current_app.config["PTG_BASE_DIR"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_set(set_id: str) -> SetManager:
|
|
25
|
+
base = _get_base_dir()
|
|
26
|
+
return SetManager.open_with_reload(str(base / set_id))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ─── Sets ────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
@api_bp.route("/sets", methods=["GET"])
|
|
32
|
+
def list_sets():
|
|
33
|
+
base = _get_base_dir()
|
|
34
|
+
sets = []
|
|
35
|
+
if base.exists():
|
|
36
|
+
for entry in sorted(base.iterdir()):
|
|
37
|
+
if not entry.is_dir():
|
|
38
|
+
continue
|
|
39
|
+
manifest_path = entry / "manifest.yaml"
|
|
40
|
+
if not manifest_path.exists():
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
sm = SetManager.open(entry)
|
|
44
|
+
sets.append({
|
|
45
|
+
"set_id": sm.manifest.get("id", entry.name),
|
|
46
|
+
"name": sm.name,
|
|
47
|
+
"author": sm.manifest.get("author", ""),
|
|
48
|
+
"description": sm.manifest.get("description", ""),
|
|
49
|
+
"version": sm.manifest.get("version", ""),
|
|
50
|
+
"dir_name": entry.name,
|
|
51
|
+
"card_count": sum(1 for _ in (entry / "catalog").glob("*.yaml")) +
|
|
52
|
+
sum(1 for _ in (entry / "catalog").glob("*.yml")),
|
|
53
|
+
})
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
return jsonify(sets)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@api_bp.route("/sets", methods=["POST"])
|
|
60
|
+
def create_set():
|
|
61
|
+
data = request.get_json(silent=True) or {}
|
|
62
|
+
set_id = data.get("set_id", "").strip()
|
|
63
|
+
name = data.get("name", "").strip()
|
|
64
|
+
if not set_id or not name:
|
|
65
|
+
return jsonify({"error": "set_id and name are required"}), 400
|
|
66
|
+
|
|
67
|
+
base = _get_base_dir()
|
|
68
|
+
set_dir = base / set_id
|
|
69
|
+
if set_dir.exists():
|
|
70
|
+
return jsonify({"error": f"Set directory already exists: {set_dir}"}), 409
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
SetManager.create(
|
|
74
|
+
directory=str(set_dir),
|
|
75
|
+
set_id=set_id,
|
|
76
|
+
name=name,
|
|
77
|
+
author=data.get("author", ""),
|
|
78
|
+
description=data.get("description", ""),
|
|
79
|
+
)
|
|
80
|
+
return jsonify({"success": True, "set_id": set_id})
|
|
81
|
+
except Exception as e:
|
|
82
|
+
return jsonify({"error": str(e)}), 500
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@api_bp.route("/sets/<set_id>/reload", methods=["POST"])
|
|
86
|
+
def reload_set(set_id: str):
|
|
87
|
+
try:
|
|
88
|
+
sm = _get_set(set_id)
|
|
89
|
+
return jsonify({
|
|
90
|
+
"success": True,
|
|
91
|
+
"name": sm.name,
|
|
92
|
+
"card_count": sm.card_count,
|
|
93
|
+
"deck_list": sm.deck_list,
|
|
94
|
+
})
|
|
95
|
+
except Exception as e:
|
|
96
|
+
return jsonify({"error": str(e)}), 500
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ─── Cards ────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
@api_bp.route("/sets/<set_id>/cards", methods=["GET"])
|
|
102
|
+
def list_cards(set_id: str):
|
|
103
|
+
try:
|
|
104
|
+
sm = _get_set(set_id)
|
|
105
|
+
cards = []
|
|
106
|
+
for card in sm.catalog.values():
|
|
107
|
+
card_type = card.type.value if hasattr(card.type, 'value') else str(card.type)
|
|
108
|
+
mana_cost = _mana_display(card.mana)
|
|
109
|
+
cards.append({
|
|
110
|
+
"card_id": card.card_id,
|
|
111
|
+
"name": card.name,
|
|
112
|
+
"type": card_type,
|
|
113
|
+
"mana": mana_cost,
|
|
114
|
+
"attack": card.attack,
|
|
115
|
+
"defense": card.defense,
|
|
116
|
+
})
|
|
117
|
+
cards.sort(key=lambda c: c["name"].lower())
|
|
118
|
+
return jsonify(cards)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
return jsonify({"error": str(e)}), 500
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@api_bp.route("/sets/<set_id>/cards/<card_id>", methods=["GET"])
|
|
124
|
+
def get_card(set_id: str, card_id: str):
|
|
125
|
+
try:
|
|
126
|
+
sm = _get_set(set_id)
|
|
127
|
+
filepath = sm.card_paths.get(card_id)
|
|
128
|
+
if not filepath:
|
|
129
|
+
return jsonify({"error": f"Card not found: {card_id}"}), 404
|
|
130
|
+
yaml_text = load_card_from_file(filepath)
|
|
131
|
+
data = yaml_to_form_data(yaml_text)
|
|
132
|
+
art = sm.get_art(card_id)
|
|
133
|
+
if art:
|
|
134
|
+
data["art_filename"] = art
|
|
135
|
+
return jsonify(data)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
return jsonify({"error": str(e)}), 500
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@api_bp.route("/sets/<set_id>/cards", methods=["POST"])
|
|
141
|
+
def save_card(set_id: str):
|
|
142
|
+
data = request.get_json(silent=True) or {}
|
|
143
|
+
card_data = data.get("data") or data
|
|
144
|
+
old_card_id = data.get("old_card_id", "")
|
|
145
|
+
|
|
146
|
+
card_id = card_data.get("card_id", "").strip()
|
|
147
|
+
if not card_id:
|
|
148
|
+
return jsonify({"error": "card_id is required"}), 400
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
sm = _get_set(set_id)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
return jsonify({"error": str(e)}), 500
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
yaml_text = card_to_yaml(card_data)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
return jsonify({"error": f"YAML generation failed: {e}"}), 400
|
|
159
|
+
|
|
160
|
+
target_path = str(sm.card_path_for(card_id))
|
|
161
|
+
|
|
162
|
+
if old_card_id and old_card_id != card_id:
|
|
163
|
+
old_path = sm.card_paths.get(old_card_id)
|
|
164
|
+
if old_path and Path(old_path).exists():
|
|
165
|
+
sm.rename_card_file(old_path, card_id)
|
|
166
|
+
sm.rename_card_in_decks(old_card_id, card_id)
|
|
167
|
+
|
|
168
|
+
if Path(target_path).exists():
|
|
169
|
+
existing_id = None
|
|
170
|
+
for cid, cpath in sm.card_paths.items():
|
|
171
|
+
if cpath == target_path and cid != card_id:
|
|
172
|
+
existing_id = cid
|
|
173
|
+
break
|
|
174
|
+
if existing_id and not data.get("overwrite", False):
|
|
175
|
+
return jsonify({
|
|
176
|
+
"error": f"Card ID '{existing_id}' already exists at this path.",
|
|
177
|
+
"conflict": True,
|
|
178
|
+
"existing_id": existing_id,
|
|
179
|
+
}), 409
|
|
180
|
+
|
|
181
|
+
save_card_yaml(yaml_text, target_path)
|
|
182
|
+
sm.reload()
|
|
183
|
+
|
|
184
|
+
return jsonify({"success": True, "card_id": card_id})
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@api_bp.route("/sets/<set_id>/cards/<card_id>", methods=["DELETE"])
|
|
188
|
+
def delete_card(set_id: str, card_id: str):
|
|
189
|
+
try:
|
|
190
|
+
sm = _get_set(set_id)
|
|
191
|
+
filepath = sm.card_paths.get(card_id)
|
|
192
|
+
if filepath and Path(filepath).exists():
|
|
193
|
+
Path(filepath).unlink()
|
|
194
|
+
sm.reload()
|
|
195
|
+
return jsonify({"success": True})
|
|
196
|
+
except Exception as e:
|
|
197
|
+
return jsonify({"error": str(e)}), 500
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ─── Preview & Validate ──────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
@api_bp.route("/preview", methods=["POST"])
|
|
203
|
+
def preview():
|
|
204
|
+
data = request.get_json(silent=True) or {}
|
|
205
|
+
try:
|
|
206
|
+
yaml_text = card_to_yaml(data)
|
|
207
|
+
return jsonify({"yaml": yaml_text})
|
|
208
|
+
except Exception as e:
|
|
209
|
+
return jsonify({"error": str(e)}), 400
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@api_bp.route("/validate", methods=["POST"])
|
|
213
|
+
def validate():
|
|
214
|
+
data = request.get_json(silent=True) or {}
|
|
215
|
+
if "yaml" in data:
|
|
216
|
+
yaml_text = data["yaml"]
|
|
217
|
+
else:
|
|
218
|
+
try:
|
|
219
|
+
yaml_text = card_to_yaml(data)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
return jsonify({"valid": False, "message": f"YAML generation failed: {e}"})
|
|
222
|
+
|
|
223
|
+
is_valid, message = validate_card_yaml(yaml_text)
|
|
224
|
+
return jsonify({"valid": is_valid, "message": message})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@api_bp.route("/sets/<set_id>/validate-all", methods=["POST"])
|
|
228
|
+
def validate_all(set_id: str):
|
|
229
|
+
try:
|
|
230
|
+
sm = _get_set(set_id)
|
|
231
|
+
results = sm.validate_all()
|
|
232
|
+
total = len(results)
|
|
233
|
+
ok = sum(1 for v in results.values() if v == "OK")
|
|
234
|
+
return jsonify({"results": results, "total": total, "ok": ok})
|
|
235
|
+
except Exception as e:
|
|
236
|
+
return jsonify({"error": str(e)}), 500
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ─── Decks ────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
@api_bp.route("/sets/<set_id>/decks", methods=["GET"])
|
|
242
|
+
def list_decks(set_id: str):
|
|
243
|
+
try:
|
|
244
|
+
sm = _get_set(set_id)
|
|
245
|
+
decks = []
|
|
246
|
+
for name in sm.deck_list:
|
|
247
|
+
try:
|
|
248
|
+
spec = sm.load_deck_spec(name)
|
|
249
|
+
total = sum(spec.values())
|
|
250
|
+
decks.append({"name": name, "total_cards": total, "spec": spec})
|
|
251
|
+
except Exception:
|
|
252
|
+
decks.append({"name": name, "total_cards": 0, "spec": {}})
|
|
253
|
+
return jsonify(decks)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
return jsonify({"error": str(e)}), 500
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@api_bp.route("/sets/<set_id>/decks/<name>", methods=["GET"])
|
|
259
|
+
def get_deck(set_id: str, name: str):
|
|
260
|
+
try:
|
|
261
|
+
sm = _get_set(set_id)
|
|
262
|
+
spec = sm.load_deck_spec(name)
|
|
263
|
+
return jsonify({"name": name, "spec": spec})
|
|
264
|
+
except Exception as e:
|
|
265
|
+
return jsonify({"error": str(e)}), 500
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@api_bp.route("/sets/<set_id>/decks", methods=["POST"])
|
|
269
|
+
def save_deck(set_id: str):
|
|
270
|
+
data = request.get_json(silent=True) or {}
|
|
271
|
+
name = data.get("name", "").strip()
|
|
272
|
+
spec = data.get("spec", {})
|
|
273
|
+
if not name:
|
|
274
|
+
return jsonify({"error": "Deck name is required"}), 400
|
|
275
|
+
if not spec:
|
|
276
|
+
return jsonify({"error": "Deck spec is empty"}), 400
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
sm = _get_set(set_id)
|
|
280
|
+
sm.add_deck(name, spec)
|
|
281
|
+
return jsonify({"success": True, "name": name})
|
|
282
|
+
except Exception as e:
|
|
283
|
+
return jsonify({"error": str(e)}), 500
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@api_bp.route("/sets/<set_id>/decks/<name>", methods=["DELETE"])
|
|
287
|
+
def delete_deck(set_id: str, name: str):
|
|
288
|
+
try:
|
|
289
|
+
sm = _get_set(set_id)
|
|
290
|
+
sm.remove_deck(name)
|
|
291
|
+
return jsonify({"success": True})
|
|
292
|
+
except Exception as e:
|
|
293
|
+
return jsonify({"error": str(e)}), 500
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ─── Art ──────────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
@api_bp.route("/sets/<set_id>/art/<card_id>", methods=["GET"])
|
|
299
|
+
def get_art(set_id: str, card_id: str):
|
|
300
|
+
try:
|
|
301
|
+
base = _get_base_dir()
|
|
302
|
+
sm = SetManager.open(str(base / set_id))
|
|
303
|
+
except Exception as e:
|
|
304
|
+
return jsonify({"error": str(e)}), 500
|
|
305
|
+
|
|
306
|
+
filename = sm.get_art(card_id)
|
|
307
|
+
if not filename:
|
|
308
|
+
return jsonify({"error": "No art for this card"}), 404
|
|
309
|
+
|
|
310
|
+
art_path = sm.art_dir / filename
|
|
311
|
+
if not art_path.exists():
|
|
312
|
+
return jsonify({"error": "Art file not found"}), 404
|
|
313
|
+
|
|
314
|
+
return send_from_directory(str(sm.art_dir), filename)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@api_bp.route("/sets/<set_id>/art/<card_id>", methods=["POST"])
|
|
318
|
+
def upload_art(set_id: str, card_id: str):
|
|
319
|
+
if "file" not in request.files:
|
|
320
|
+
return jsonify({"error": "No file provided"}), 400
|
|
321
|
+
|
|
322
|
+
file = request.files["file"]
|
|
323
|
+
if file.filename == "":
|
|
324
|
+
return jsonify({"error": "No file selected"}), 400
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
base = _get_base_dir()
|
|
328
|
+
sm = SetManager.open(str(base / set_id))
|
|
329
|
+
except Exception as e:
|
|
330
|
+
return jsonify({"error": str(e)}), 500
|
|
331
|
+
|
|
332
|
+
ext = Path(file.filename).suffix.lower()
|
|
333
|
+
if ext not in (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"):
|
|
334
|
+
return jsonify({"error": f"Unsupported image format: {ext}"}), 400
|
|
335
|
+
|
|
336
|
+
dest_filename = f"{card_id}{ext}"
|
|
337
|
+
dest_path = sm.art_dir / dest_filename
|
|
338
|
+
sm.art_dir.mkdir(exist_ok=True)
|
|
339
|
+
file.save(str(dest_path))
|
|
340
|
+
|
|
341
|
+
# Remove old art file if different extension
|
|
342
|
+
old_filename = sm.get_art(card_id)
|
|
343
|
+
if old_filename and old_filename != dest_filename:
|
|
344
|
+
old_path = sm.art_dir / old_filename
|
|
345
|
+
if old_path.exists():
|
|
346
|
+
old_path.unlink()
|
|
347
|
+
|
|
348
|
+
sm.set_art(card_id, dest_filename)
|
|
349
|
+
return jsonify({"success": True, "filename": dest_filename})
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@api_bp.route("/sets/<set_id>/art/<card_id>", methods=["DELETE"])
|
|
353
|
+
def remove_art(set_id: str, card_id: str):
|
|
354
|
+
try:
|
|
355
|
+
base = _get_base_dir()
|
|
356
|
+
sm = SetManager.open(str(base / set_id))
|
|
357
|
+
except Exception as e:
|
|
358
|
+
return jsonify({"error": str(e)}), 500
|
|
359
|
+
|
|
360
|
+
old_filename = sm.get_art(card_id)
|
|
361
|
+
if old_filename:
|
|
362
|
+
old_path = sm.art_dir / old_filename
|
|
363
|
+
if old_path.exists():
|
|
364
|
+
old_path.unlink()
|
|
365
|
+
sm.remove_art(card_id)
|
|
366
|
+
return jsonify({"success": True})
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ─── Helpers ──────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
@api_bp.route("/sets/<set_id>/manifest", methods=["POST"])
|
|
372
|
+
def save_manifest(set_id: str):
|
|
373
|
+
data = request.get_json(silent=True) or {}
|
|
374
|
+
try:
|
|
375
|
+
sm = _get_set(set_id)
|
|
376
|
+
m = sm.manifest
|
|
377
|
+
for key in ("id", "name", "version", "author", "description", "engine_version"):
|
|
378
|
+
if key in data:
|
|
379
|
+
m[key] = data[key]
|
|
380
|
+
sm.save_manifest()
|
|
381
|
+
return jsonify({"success": True})
|
|
382
|
+
except Exception as e:
|
|
383
|
+
return jsonify({"error": str(e)}), 500
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _mana_display(mana_dict: dict) -> str:
|
|
387
|
+
if not mana_dict:
|
|
388
|
+
return ""
|
|
389
|
+
parts = []
|
|
390
|
+
for mt, val in mana_dict.items():
|
|
391
|
+
key = mt.value if hasattr(mt, 'value') else str(mt)
|
|
392
|
+
if val > 0:
|
|
393
|
+
label = "Y" if key == "any" else key[0].upper()
|
|
394
|
+
parts.append(f"{label}{val}")
|
|
395
|
+
return " ".join(parts)
|