ptg-editor 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ptg_editor/__init__.py ADDED
File without changes
ptg_editor/app.py ADDED
@@ -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()
ptg_editor/config.py ADDED
@@ -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()
ptg_editor/main.py ADDED
@@ -0,0 +1,8 @@
1
+ """Entry point for PTG Editor Flask server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ptg_editor.app import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
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)