decksmith 0.1.15__py3-none-any.whl → 0.9.1__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.
- decksmith/card_builder.py +78 -565
- decksmith/deck_builder.py +38 -54
- decksmith/export.py +170 -168
- decksmith/gui/__init__.py +0 -0
- decksmith/gui/app.py +341 -0
- decksmith/gui/static/css/style.css +656 -0
- decksmith/gui/static/js/main.js +583 -0
- decksmith/gui/templates/index.html +182 -0
- decksmith/image_ops.py +121 -0
- decksmith/logger.py +39 -0
- decksmith/macro.py +46 -0
- decksmith/main.py +31 -23
- decksmith/project.py +111 -0
- decksmith/renderers/__init__.py +3 -0
- decksmith/renderers/image.py +76 -0
- decksmith/renderers/shapes.py +237 -0
- decksmith/renderers/text.py +127 -0
- decksmith/templates/deck.csv +4 -5
- decksmith/templates/deck.yaml +46 -0
- decksmith/utils.py +75 -69
- decksmith/validate.py +132 -132
- {decksmith-0.1.15.dist-info → decksmith-0.9.1.dist-info}/METADATA +22 -16
- decksmith-0.9.1.dist-info/RECORD +26 -0
- {decksmith-0.1.15.dist-info → decksmith-0.9.1.dist-info}/WHEEL +1 -1
- decksmith/templates/deck.json +0 -31
- decksmith-0.1.15.dist-info/RECORD +0 -13
- {decksmith-0.1.15.dist-info → decksmith-0.9.1.dist-info}/entry_points.txt +0 -0
decksmith/gui/app.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains the Flask application for the DeckSmith GUI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import webbrowser
|
|
12
|
+
from io import StringIO
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from threading import Timer
|
|
15
|
+
|
|
16
|
+
import pandas as pd
|
|
17
|
+
from flask import (
|
|
18
|
+
Flask,
|
|
19
|
+
Response,
|
|
20
|
+
jsonify,
|
|
21
|
+
render_template,
|
|
22
|
+
request,
|
|
23
|
+
send_file,
|
|
24
|
+
stream_with_context,
|
|
25
|
+
)
|
|
26
|
+
from platformdirs import user_documents_dir
|
|
27
|
+
from ruamel.yaml import YAML
|
|
28
|
+
from waitress import serve
|
|
29
|
+
|
|
30
|
+
from decksmith.card_builder import CardBuilder
|
|
31
|
+
from decksmith.deck_builder import DeckBuilder
|
|
32
|
+
from decksmith.export import PdfExporter
|
|
33
|
+
from decksmith.logger import logger
|
|
34
|
+
from decksmith.macro import MacroResolver
|
|
35
|
+
from decksmith.project import ProjectManager
|
|
36
|
+
|
|
37
|
+
app = Flask(__name__)
|
|
38
|
+
|
|
39
|
+
shutdown_event = threading.Event()
|
|
40
|
+
SHUTDOWN_REASON = "Server stopped."
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def signal_handler(sig, frame): # pylint: disable=unused-argument
|
|
44
|
+
"""Handles system signals (e.g., SIGINT)."""
|
|
45
|
+
global SHUTDOWN_REASON # pylint: disable=global-statement
|
|
46
|
+
SHUTDOWN_REASON = "Server stopped by user (Ctrl+C)."
|
|
47
|
+
logger.info(SHUTDOWN_REASON)
|
|
48
|
+
shutdown_event.set()
|
|
49
|
+
|
|
50
|
+
def delayed_exit():
|
|
51
|
+
time.sleep(1)
|
|
52
|
+
os._exit(0)
|
|
53
|
+
|
|
54
|
+
threading.Thread(target=delayed_exit).start()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.route("/api/events")
|
|
61
|
+
def events():
|
|
62
|
+
"""Streams server events to the client."""
|
|
63
|
+
|
|
64
|
+
def stream():
|
|
65
|
+
while not shutdown_event.is_set():
|
|
66
|
+
time.sleep(0.5)
|
|
67
|
+
yield ": keepalive\n\n"
|
|
68
|
+
|
|
69
|
+
yield f"data: {json.dumps({'type': 'shutdown', 'reason': SHUTDOWN_REASON})}\n\n"
|
|
70
|
+
|
|
71
|
+
return Response(stream_with_context(stream()), mimetype="text/event-stream")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Configuration
|
|
75
|
+
project_manager = ProjectManager()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.route("/")
|
|
79
|
+
def index():
|
|
80
|
+
"""Renders the main page."""
|
|
81
|
+
return render_template("index.html")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.route("/api/project/current", methods=["GET"])
|
|
85
|
+
def get_current_project():
|
|
86
|
+
"""Returns the current project path."""
|
|
87
|
+
working_dir = project_manager.get_working_dir()
|
|
88
|
+
return jsonify({"path": str(working_dir) if working_dir else None})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.route("/api/system/default-path", methods=["GET"])
|
|
92
|
+
def get_default_path():
|
|
93
|
+
"""Returns the default project path."""
|
|
94
|
+
default_path = Path(user_documents_dir()) / "DeckSmith"
|
|
95
|
+
try:
|
|
96
|
+
default_path.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
return jsonify({"path": str(default_path)})
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.error("Error creating default path: %s", e)
|
|
100
|
+
return jsonify({"error": str(e)}), 500
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.route("/api/system/browse", methods=["POST"])
|
|
104
|
+
def browse_folder():
|
|
105
|
+
"""Opens a folder selection dialog."""
|
|
106
|
+
try:
|
|
107
|
+
import crossfiledialog # pylint: disable=import-outside-toplevel
|
|
108
|
+
|
|
109
|
+
folder_path = crossfiledialog.choose_folder(
|
|
110
|
+
title="Select Project Folder",
|
|
111
|
+
start_dir=str(Path.home()),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if folder_path:
|
|
115
|
+
return jsonify({"path": folder_path})
|
|
116
|
+
return jsonify({"path": None})
|
|
117
|
+
except ImportError as e:
|
|
118
|
+
logger.error("crossfiledialog import failed: %s", e)
|
|
119
|
+
return jsonify({"error": f"Browse feature unavailable: {e}"}), 501
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.error("Error browsing folder: %s", e)
|
|
122
|
+
return jsonify({"error": str(e)}), 500
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.route("/api/project/select", methods=["POST"])
|
|
126
|
+
def select_project():
|
|
127
|
+
"""Selects a project directory."""
|
|
128
|
+
path_str = request.json.get("path")
|
|
129
|
+
if not path_str:
|
|
130
|
+
return jsonify({"error": "Path is required"}), 400
|
|
131
|
+
|
|
132
|
+
path = Path(path_str)
|
|
133
|
+
if not path.exists() or not path.is_dir():
|
|
134
|
+
return jsonify({"error": "Directory does not exist"}), 400
|
|
135
|
+
|
|
136
|
+
project_manager.set_working_dir(path)
|
|
137
|
+
return jsonify({"status": "success", "path": str(path)})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.route("/api/project/close", methods=["POST"])
|
|
141
|
+
def close_project():
|
|
142
|
+
"""Closes the current project."""
|
|
143
|
+
project_manager.close_project()
|
|
144
|
+
return jsonify({"status": "success"})
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.route("/api/project/create", methods=["POST"])
|
|
148
|
+
def create_project():
|
|
149
|
+
"""Creates a new project."""
|
|
150
|
+
path_str = request.json.get("path")
|
|
151
|
+
if not path_str:
|
|
152
|
+
return jsonify({"error": "Path is required"}), 400
|
|
153
|
+
|
|
154
|
+
path = Path(path_str)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
project_manager.create_project(path)
|
|
158
|
+
return jsonify({"status": "success", "path": str(path)})
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.error("Error creating project: %s", e)
|
|
161
|
+
return jsonify({"error": str(e)}), 500
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.route("/api/load", methods=["GET"])
|
|
165
|
+
def load_files():
|
|
166
|
+
"""Loads project files."""
|
|
167
|
+
data = project_manager.load_files()
|
|
168
|
+
return jsonify(data)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.route("/api/save", methods=["POST"])
|
|
172
|
+
def save_files():
|
|
173
|
+
"""Saves project files."""
|
|
174
|
+
if project_manager.get_working_dir() is None:
|
|
175
|
+
return jsonify({"error": "No project selected"}), 400
|
|
176
|
+
|
|
177
|
+
data = request.json
|
|
178
|
+
yaml_content = data.get("yaml")
|
|
179
|
+
csv_content = data.get("csv")
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
project_manager.save_files(yaml_content, csv_content)
|
|
183
|
+
return jsonify({"status": "success"})
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.error("Error saving files: %s", e)
|
|
186
|
+
return jsonify({"error": str(e)}), 500
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.route("/api/cards", methods=["GET"])
|
|
190
|
+
def list_cards():
|
|
191
|
+
"""Lists cards in the project."""
|
|
192
|
+
working_dir = project_manager.get_working_dir()
|
|
193
|
+
if working_dir is None:
|
|
194
|
+
return jsonify([])
|
|
195
|
+
|
|
196
|
+
csv_path = working_dir / "deck.csv"
|
|
197
|
+
if not csv_path.exists():
|
|
198
|
+
return jsonify([])
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
csv_table = pd.read_csv(csv_path, sep=";")
|
|
202
|
+
return jsonify(csv_table.to_dict(orient="records"))
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.error("Error listing cards: %s", e)
|
|
205
|
+
return jsonify({"error": str(e)}), 400
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.route("/api/preview/<int:card_index>", methods=["POST"])
|
|
209
|
+
def preview_card(card_index):
|
|
210
|
+
"""Previews a specific card."""
|
|
211
|
+
working_dir = project_manager.get_working_dir()
|
|
212
|
+
# Allow preview even without project, but base_path will be None
|
|
213
|
+
|
|
214
|
+
data = request.json
|
|
215
|
+
yaml_content = data.get("yaml")
|
|
216
|
+
csv_content = data.get("csv")
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
yaml = YAML()
|
|
220
|
+
spec = yaml.load(yaml_content)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
return jsonify({"error": f"Invalid YAML: {e}"}), 400
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
csv_table = pd.read_csv(StringIO(csv_content), sep=";")
|
|
226
|
+
if card_index < 0 or card_index >= len(csv_table):
|
|
227
|
+
return jsonify({"error": "Index out of bounds"}), 400
|
|
228
|
+
row = csv_table.iloc[card_index]
|
|
229
|
+
except Exception as e:
|
|
230
|
+
return jsonify({"error": f"Invalid CSV: {e}"}), 400
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
row_dict = row.to_dict()
|
|
234
|
+
resolved_spec = MacroResolver.resolve(spec, row_dict)
|
|
235
|
+
|
|
236
|
+
builder = CardBuilder(resolved_spec, base_path=working_dir)
|
|
237
|
+
card_image = builder.render()
|
|
238
|
+
|
|
239
|
+
image_buffer = io.BytesIO()
|
|
240
|
+
card_image.save(image_buffer, "PNG")
|
|
241
|
+
image_buffer.seek(0)
|
|
242
|
+
return send_file(image_buffer, mimetype="image/png")
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.error("Error previewing card: %s", e)
|
|
246
|
+
return jsonify({"error": str(e)}), 500
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@app.route("/api/build", methods=["POST"])
|
|
250
|
+
def build_deck():
|
|
251
|
+
"""Builds the deck."""
|
|
252
|
+
working_dir = project_manager.get_working_dir()
|
|
253
|
+
if working_dir is None:
|
|
254
|
+
return jsonify({"error": "No project selected"}), 400
|
|
255
|
+
|
|
256
|
+
# Save current state first
|
|
257
|
+
data = request.json
|
|
258
|
+
yaml_content = data.get("yaml")
|
|
259
|
+
csv_content = data.get("csv")
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
project_manager.save_files(yaml_content, csv_content)
|
|
263
|
+
|
|
264
|
+
yaml_path = working_dir / "deck.yaml"
|
|
265
|
+
csv_path = working_dir / "deck.csv"
|
|
266
|
+
output_path = working_dir / "output"
|
|
267
|
+
output_path.mkdir(exist_ok=True)
|
|
268
|
+
|
|
269
|
+
builder = DeckBuilder(yaml_path, csv_path)
|
|
270
|
+
builder.build_deck(output_path)
|
|
271
|
+
|
|
272
|
+
return jsonify({"status": "success", "message": f"Deck built in {output_path}"})
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.error("Error building deck: %s", e)
|
|
275
|
+
return jsonify({"error": str(e)}), 500
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@app.route("/api/export", methods=["POST"])
|
|
279
|
+
def export_pdf():
|
|
280
|
+
"""Exports the deck to PDF."""
|
|
281
|
+
working_dir = project_manager.get_working_dir()
|
|
282
|
+
if working_dir is None:
|
|
283
|
+
return jsonify({"error": "No project selected"}), 400
|
|
284
|
+
|
|
285
|
+
data = request.json or {}
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
image_folder = working_dir / "output"
|
|
289
|
+
|
|
290
|
+
filename = data.get("filename", "deck.pdf")
|
|
291
|
+
if not filename.endswith(".pdf"):
|
|
292
|
+
filename += ".pdf"
|
|
293
|
+
|
|
294
|
+
output_pdf = working_dir / filename
|
|
295
|
+
|
|
296
|
+
if not image_folder.exists():
|
|
297
|
+
return jsonify(
|
|
298
|
+
{"error": "Output folder does not exist. Build deck first."}
|
|
299
|
+
), 400
|
|
300
|
+
|
|
301
|
+
exporter = PdfExporter(
|
|
302
|
+
image_folder=image_folder,
|
|
303
|
+
output_path=output_pdf,
|
|
304
|
+
page_size_str=data.get("page_size", "A4"),
|
|
305
|
+
image_width=float(data.get("width", 63.5)),
|
|
306
|
+
image_height=float(data.get("height", 88.9)),
|
|
307
|
+
gap=float(data.get("gap", 0)),
|
|
308
|
+
margins=(float(data.get("margin_x", 2)), float(data.get("margin_y", 2))),
|
|
309
|
+
)
|
|
310
|
+
exporter.export()
|
|
311
|
+
|
|
312
|
+
return jsonify(
|
|
313
|
+
{"status": "success", "message": f"PDF exported to {output_pdf}"}
|
|
314
|
+
)
|
|
315
|
+
except Exception as e:
|
|
316
|
+
logger.error("Error exporting PDF: %s", e)
|
|
317
|
+
return jsonify({"error": str(e)}), 500
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def open_browser():
|
|
321
|
+
"""Opens the browser."""
|
|
322
|
+
webbrowser.open_new("http://127.0.0.1:5000/")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@app.route("/api/shutdown", methods=["POST"])
|
|
326
|
+
def shutdown():
|
|
327
|
+
"""Shuts down the server."""
|
|
328
|
+
logger.info("Shutdown signal received. Shutting down.")
|
|
329
|
+
os._exit(0)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def main():
|
|
333
|
+
"""Main entry point for the GUI."""
|
|
334
|
+
# Open browser after a short delay to ensure server is running
|
|
335
|
+
Timer(1, open_browser).start()
|
|
336
|
+
|
|
337
|
+
serve(app, host="127.0.0.1", port=5000)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if __name__ == "__main__":
|
|
341
|
+
main()
|