decksmith 0.1.14__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 +140 -627
- decksmith/deck_builder.py +85 -101
- decksmith/export.py +29 -27
- 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 +146 -138
- 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 +19 -13
- decksmith/validate.py +1 -1
- {decksmith-0.1.14.dist-info → decksmith-0.9.1.dist-info}/METADATA +53 -19
- decksmith-0.9.1.dist-info/RECORD +26 -0
- {decksmith-0.1.14.dist-info → decksmith-0.9.1.dist-info}/WHEEL +1 -1
- decksmith/templates/deck.json +0 -31
- decksmith-0.1.14.dist-info/RECORD +0 -13
- {decksmith-0.1.14.dist-info → decksmith-0.9.1.dist-info}/entry_points.txt +0 -0
decksmith/deck_builder.py
CHANGED
|
@@ -1,101 +1,85 @@
|
|
|
1
|
-
"""
|
|
2
|
-
This module contains the DeckBuilder class,
|
|
3
|
-
which is used to create a deck of cards based on a
|
|
4
|
-
and a CSV file.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import concurrent.futures
|
|
8
|
-
import
|
|
9
|
-
from
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
from
|
|
14
|
-
|
|
15
|
-
from .card_builder import CardBuilder
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
self.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def build_card(row_tuple: tuple[int, Series]):
|
|
87
|
-
"""
|
|
88
|
-
Builds a single card from a row of the CSV file.
|
|
89
|
-
Args:
|
|
90
|
-
row_tuple (tuple[int, Series]): A tuple containing the row index and the row data.
|
|
91
|
-
"""
|
|
92
|
-
idx, row = row_tuple
|
|
93
|
-
spec = self._replace_macros(row.to_dict())
|
|
94
|
-
card_builder = CardBuilder(spec)
|
|
95
|
-
card_builder.build(output_path / f"card_{idx + 1}.png")
|
|
96
|
-
|
|
97
|
-
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
98
|
-
list(executor.map(build_card, df.iterrows()))
|
|
99
|
-
|
|
100
|
-
# for row_tuple in df.iterrows():
|
|
101
|
-
# build_card(row_tuple)
|
|
1
|
+
"""
|
|
2
|
+
This module contains the DeckBuilder class,
|
|
3
|
+
which is used to create a deck of cards based on a YAML specification
|
|
4
|
+
and a CSV file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import concurrent.futures
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from pandas import Series
|
|
13
|
+
from ruamel.yaml import YAML
|
|
14
|
+
|
|
15
|
+
from decksmith.card_builder import CardBuilder
|
|
16
|
+
from decksmith.logger import logger
|
|
17
|
+
from decksmith.macro import MacroResolver
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DeckBuilder:
|
|
21
|
+
"""
|
|
22
|
+
A class to build a deck of cards based on a YAML specification and a CSV file.
|
|
23
|
+
Attributes:
|
|
24
|
+
spec_path (Path): Path to the YAML specification file.
|
|
25
|
+
csv_path (Union[Path, None]): Path to the CSV file containing card data.
|
|
26
|
+
cards (list): List of CardBuilder instances for each card in the deck.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, spec_path: Path, csv_path: Optional[Path] = None):
|
|
30
|
+
"""
|
|
31
|
+
Initializes the DeckBuilder with a YAML specification file and a CSV file.
|
|
32
|
+
Args:
|
|
33
|
+
spec_path (Path): Path to the YAML specification file.
|
|
34
|
+
csv_path (Union[Path, None]): Path to the CSV file containing card data.
|
|
35
|
+
"""
|
|
36
|
+
self.spec_path = spec_path
|
|
37
|
+
self.csv_path = csv_path
|
|
38
|
+
self.cards: List[CardBuilder] = []
|
|
39
|
+
self._spec_cache: Optional[Dict[str, Any]] = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def spec(self) -> Dict[str, Any]:
|
|
43
|
+
"""Loads and caches the spec file."""
|
|
44
|
+
if self._spec_cache is None:
|
|
45
|
+
yaml = YAML()
|
|
46
|
+
with open(self.spec_path, "r", encoding="utf-8") as spec_file:
|
|
47
|
+
self._spec_cache = yaml.load(spec_file)
|
|
48
|
+
return self._spec_cache
|
|
49
|
+
|
|
50
|
+
def build_deck(self, output_path: Path):
|
|
51
|
+
"""
|
|
52
|
+
Builds the deck of cards by reading the CSV file and creating CardBuilder instances.
|
|
53
|
+
"""
|
|
54
|
+
base_path = self.spec_path.parent if self.spec_path else None
|
|
55
|
+
|
|
56
|
+
if not self.csv_path or not self.csv_path.exists():
|
|
57
|
+
logger.info("No CSV file found. Building single card from spec.")
|
|
58
|
+
card_builder = CardBuilder(self.spec, base_path=base_path)
|
|
59
|
+
card_builder.build(output_path / "card_1.png")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
dataframe = pd.read_csv(self.csv_path, encoding="utf-8", sep=";", header=0)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.error("Error reading CSV file: %s", e)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
def build_card(row_tuple: tuple[int, Series]):
|
|
69
|
+
"""
|
|
70
|
+
Builds a single card from a row of the CSV file.
|
|
71
|
+
Args:
|
|
72
|
+
row_tuple (tuple[int, Series]): A tuple containing the row index and the row data.
|
|
73
|
+
"""
|
|
74
|
+
idx, row = row_tuple
|
|
75
|
+
try:
|
|
76
|
+
# We need a deep copy of the spec for each card to avoid side effects
|
|
77
|
+
# But resolve_macros creates a new structure, so it should be fine
|
|
78
|
+
spec = MacroResolver.resolve(self.spec, row.to_dict())
|
|
79
|
+
card_builder = CardBuilder(spec, base_path=base_path)
|
|
80
|
+
card_builder.build(output_path / f"card_{idx + 1}.png")
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.error("Error building card %s: %s", idx + 1, e)
|
|
83
|
+
|
|
84
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
85
|
+
list(executor.map(build_card, dataframe.iterrows()))
|
decksmith/export.py
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
This module provides the functionality to export images from a folder to a PDF file.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
import logging
|
|
6
5
|
from pathlib import Path
|
|
7
6
|
from typing import List, Tuple
|
|
8
7
|
|
|
@@ -10,6 +9,8 @@ from reportlab.lib.pagesizes import A4
|
|
|
10
9
|
from reportlab.lib.units import mm
|
|
11
10
|
from reportlab.pdfgen import canvas
|
|
12
11
|
|
|
12
|
+
from decksmith.logger import logger
|
|
13
|
+
|
|
13
14
|
|
|
14
15
|
class PdfExporter:
|
|
15
16
|
"""
|
|
@@ -46,9 +47,9 @@ class PdfExporter:
|
|
|
46
47
|
image_extensions = {".png", ".jpg", ".jpeg", ".webp"}
|
|
47
48
|
return sorted(
|
|
48
49
|
[
|
|
49
|
-
|
|
50
|
-
for
|
|
51
|
-
if
|
|
50
|
+
image_path
|
|
51
|
+
for image_path in self.image_folder.iterdir()
|
|
52
|
+
if image_path.suffix.lower() in image_extensions
|
|
52
53
|
]
|
|
53
54
|
)
|
|
54
55
|
|
|
@@ -82,17 +83,18 @@ class PdfExporter:
|
|
|
82
83
|
best_layout = (0, 0, False)
|
|
83
84
|
|
|
84
85
|
for rotated in [False, True]:
|
|
85
|
-
|
|
86
|
+
image_width, image_height = (
|
|
86
87
|
(self.image_width, self.image_height)
|
|
87
88
|
if not rotated
|
|
88
89
|
else (self.image_height, self.image_width)
|
|
89
90
|
)
|
|
90
91
|
|
|
91
92
|
cols = int(
|
|
92
|
-
(page_width - 2 * self.margins[0] + self.gap) / (
|
|
93
|
+
(page_width - 2 * self.margins[0] + self.gap) / (image_width + self.gap)
|
|
93
94
|
)
|
|
94
95
|
rows = int(
|
|
95
|
-
(page_height - 2 * self.margins[1] + self.gap)
|
|
96
|
+
(page_height - 2 * self.margins[1] + self.gap)
|
|
97
|
+
/ (image_height + self.gap)
|
|
96
98
|
)
|
|
97
99
|
|
|
98
100
|
if cols * rows > best_fit:
|
|
@@ -112,16 +114,16 @@ class PdfExporter:
|
|
|
112
114
|
if cols == 0 or rows == 0:
|
|
113
115
|
raise ValueError("The images are too large to fit on the page.")
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
image_width, image_height = (
|
|
116
118
|
(self.image_width, self.image_height)
|
|
117
119
|
if not rotated
|
|
118
120
|
else (self.image_height, self.image_width)
|
|
119
121
|
)
|
|
120
122
|
|
|
121
|
-
total_width = cols *
|
|
122
|
-
total_height = rows *
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
total_width = cols * image_width + (cols - 1) * self.gap
|
|
124
|
+
total_height = rows * image_height + (rows - 1) * self.gap
|
|
125
|
+
start_horizontal = (page_width - total_width) / 2
|
|
126
|
+
start_vertical = (page_height - total_height) / 2
|
|
125
127
|
|
|
126
128
|
images_on_page = 0
|
|
127
129
|
for image_path in self.image_paths:
|
|
@@ -132,37 +134,37 @@ class PdfExporter:
|
|
|
132
134
|
row = images_on_page // cols
|
|
133
135
|
col = images_on_page % cols
|
|
134
136
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
position_horizontal = start_horizontal + col * (image_width + self.gap)
|
|
138
|
+
position_vertical = start_vertical + row * (image_height + self.gap)
|
|
137
139
|
|
|
138
140
|
if not rotated:
|
|
139
141
|
self.pdf.drawImage(
|
|
140
142
|
str(image_path),
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
width=
|
|
144
|
-
height=
|
|
143
|
+
position_horizontal,
|
|
144
|
+
position_vertical,
|
|
145
|
+
width=image_width,
|
|
146
|
+
height=image_height,
|
|
145
147
|
preserveAspectRatio=True,
|
|
146
148
|
)
|
|
147
149
|
else:
|
|
148
150
|
self.pdf.saveState()
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
self.pdf.translate(
|
|
151
|
+
center_horizontal = position_horizontal + image_width / 2
|
|
152
|
+
center_vertical = position_vertical + image_height / 2
|
|
153
|
+
self.pdf.translate(center_horizontal, center_vertical)
|
|
152
154
|
self.pdf.rotate(90)
|
|
153
155
|
self.pdf.drawImage(
|
|
154
156
|
str(image_path),
|
|
155
|
-
-
|
|
156
|
-
-
|
|
157
|
-
width=
|
|
158
|
-
height=
|
|
157
|
+
-image_height / 2,
|
|
158
|
+
-image_width / 2,
|
|
159
|
+
width=image_height,
|
|
160
|
+
height=image_width,
|
|
159
161
|
preserveAspectRatio=True,
|
|
160
162
|
)
|
|
161
163
|
self.pdf.restoreState()
|
|
162
164
|
images_on_page += 1
|
|
163
165
|
|
|
164
166
|
self.pdf.save()
|
|
165
|
-
|
|
167
|
+
logger.info("Successfully exported PDF to %s", self.output_path)
|
|
166
168
|
except Exception as e:
|
|
167
|
-
|
|
169
|
+
logger.error("An error occurred during PDF export: %s", e)
|
|
168
170
|
raise
|
|
File without changes
|
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()
|