decksmith 0.1.15__py3-none-any.whl → 0.9.2__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/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()