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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>DeckSmith</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
|
|
10
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
11
|
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
|
12
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.6/ace.js"></script>
|
|
13
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/split.js/1.6.0/split.min.js"></script>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<div id="header">
|
|
17
|
+
<div class="app-branding">
|
|
18
|
+
<i class="fa-solid fa-layer-group"></i>
|
|
19
|
+
<div class="app-title">DeckSmith</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="project-controls">
|
|
22
|
+
<div class="project-path-container">
|
|
23
|
+
<i class="fa-regular fa-folder"></i>
|
|
24
|
+
<span id="current-project-path" title="Current Project Path">No project open</span>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="button-group">
|
|
27
|
+
<button id="open-project-btn" class="btn small" title="Open Project"><i class="fa-solid fa-folder-open"></i> Open</button>
|
|
28
|
+
<button id="new-project-btn" class="btn small" title="New Project"><i class="fa-solid fa-plus"></i> New</button>
|
|
29
|
+
<button id="close-project-btn" class="btn small" title="Close Project"><i class="fa-solid fa-xmark"></i> Close</button>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="app-container">
|
|
35
|
+
<div id="left-pane" class="split split-horizontal">
|
|
36
|
+
<div id="editors-container">
|
|
37
|
+
<div id="yaml-pane" class="split split-vertical">
|
|
38
|
+
<div class="pane-header">
|
|
39
|
+
<span><i class="fa-solid fa-code"></i> Layout (YAML)</span>
|
|
40
|
+
</div>
|
|
41
|
+
<div id="yaml-editor"></div>
|
|
42
|
+
</div>
|
|
43
|
+
<div id="csv-pane" class="split split-vertical">
|
|
44
|
+
<div class="pane-header">
|
|
45
|
+
<span><i class="fa-solid fa-table"></i> Data (CSV)</span>
|
|
46
|
+
</div>
|
|
47
|
+
<div id="csv-editor"></div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div id="right-pane" class="split split-horizontal">
|
|
52
|
+
<div class="preview-wrapper">
|
|
53
|
+
<div class="controls">
|
|
54
|
+
<div class="control-group">
|
|
55
|
+
<label for="card-selector"><i class="fa-solid fa-list"></i> Card:</label>
|
|
56
|
+
<div class="select-wrapper">
|
|
57
|
+
<select id="card-selector">
|
|
58
|
+
<option value="-1">Select a card...</option>
|
|
59
|
+
</select>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="control-group actions">
|
|
63
|
+
<button id="build-btn" class="btn primary"><i class="fa-solid fa-hammer"></i> Build Deck</button>
|
|
64
|
+
<button id="export-btn" class="btn danger"><i class="fa-solid fa-file-pdf"></i> Export PDF</button>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
<div id="status-line" class="status-line"></div>
|
|
68
|
+
<div class="preview-area">
|
|
69
|
+
<div id="loading-indicator" class="hidden">
|
|
70
|
+
<i class="fa-solid fa-circle-notch fa-spin fa-2x"></i>
|
|
71
|
+
</div>
|
|
72
|
+
<img id="preview-image" src="" alt="Card Preview" class="hidden">
|
|
73
|
+
<div id="placeholder-text">
|
|
74
|
+
<span>Select a card to preview</span>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
<div id="status-bar">
|
|
78
|
+
<i id="status-spinner" class="fa-solid fa-circle-notch fa-spin fa-fw hidden" style="font-size: 0.9em;"></i>
|
|
79
|
+
<i id="status-icon" class="fa-solid fa-circle-info fa-fw" style="font-size: 0.9em;"></i>
|
|
80
|
+
<span id="status-text">Ready</span>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
<div id="toast-container"></div>
|
|
86
|
+
|
|
87
|
+
<!-- Welcome Screen -->
|
|
88
|
+
<div id="welcome-screen" class="modal hidden">
|
|
89
|
+
<div class="modal-overlay"></div>
|
|
90
|
+
<div class="modal-content welcome-content">
|
|
91
|
+
<h1>Welcome to DeckSmith</h1>
|
|
92
|
+
<p>Create and manage your card decks with ease.<br>Select a project to get started.</p>
|
|
93
|
+
<div class="welcome-actions">
|
|
94
|
+
<button id="welcome-open-btn" class="btn primary large"><i class="fa-solid fa-folder-open"></i> Open Project</button>
|
|
95
|
+
<button id="welcome-new-btn" class="btn secondary large"><i class="fa-solid fa-plus"></i> New Project</button>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<!-- Shutdown Screen -->
|
|
101
|
+
<div id="shutdown-screen" class="modal hidden">
|
|
102
|
+
<div class="modal-overlay"></div>
|
|
103
|
+
<div class="modal-content welcome-content">
|
|
104
|
+
<div style="margin-bottom: 20px;">
|
|
105
|
+
<i class="fa-solid fa-power-off" style="font-size: 3em; color: var(--danger-color);"></i>
|
|
106
|
+
</div>
|
|
107
|
+
<h1>Service Stopped</h1>
|
|
108
|
+
<p id="shutdown-reason" style="font-weight: 500; color: var(--text-primary);">The DeckSmith service stopped.</p>
|
|
109
|
+
<p>You can close this window.<br>To continue working, simply launch the app again.</p>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<!-- Modal -->
|
|
114
|
+
<div id="path-modal" class="modal hidden">
|
|
115
|
+
<div class="modal-overlay"></div>
|
|
116
|
+
<div class="modal-content">
|
|
117
|
+
<h3 id="modal-title">Select Project</h3>
|
|
118
|
+
<div class="form-group">
|
|
119
|
+
<label for="project-path-input" id="project-path-label">Folder Path:</label>
|
|
120
|
+
<div class="input-group">
|
|
121
|
+
<input type="text" id="project-path-input" placeholder="e.g. C:/Projects/MyDeck">
|
|
122
|
+
<button id="browse-btn" class="btn secondary">Browse...</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="form-group hidden" id="project-name-group">
|
|
126
|
+
<label for="project-name-input">Project Name:</label>
|
|
127
|
+
<input type="text" id="project-name-input" placeholder="MyNewDeck">
|
|
128
|
+
</div>
|
|
129
|
+
<div class="modal-actions">
|
|
130
|
+
<button id="modal-cancel-btn" class="btn secondary">Cancel</button>
|
|
131
|
+
<button id="modal-confirm-btn" class="btn primary">Confirm</button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<!-- Export Modal -->
|
|
137
|
+
<div id="export-modal" class="modal hidden">
|
|
138
|
+
<div class="modal-overlay"></div>
|
|
139
|
+
<div class="modal-content">
|
|
140
|
+
<h3>Export PDF</h3>
|
|
141
|
+
<div class="form-grid">
|
|
142
|
+
<div class="form-group">
|
|
143
|
+
<label for="export-filename">Filename:</label>
|
|
144
|
+
<input type="text" id="export-filename" value="deck.pdf">
|
|
145
|
+
</div>
|
|
146
|
+
<div class="form-group">
|
|
147
|
+
<label for="export-page-size">Page Size:</label>
|
|
148
|
+
<select id="export-page-size">
|
|
149
|
+
<option value="A4" selected>A4</option>
|
|
150
|
+
<option value="Letter">Letter</option>
|
|
151
|
+
</select>
|
|
152
|
+
</div>
|
|
153
|
+
<div class="form-group">
|
|
154
|
+
<label for="export-width">Width (mm):</label>
|
|
155
|
+
<input type="number" id="export-width" value="63.5" step="0.1">
|
|
156
|
+
</div>
|
|
157
|
+
<div class="form-group">
|
|
158
|
+
<label for="export-height">Height (mm):</label>
|
|
159
|
+
<input type="number" id="export-height" value="88.9" step="0.1">
|
|
160
|
+
</div>
|
|
161
|
+
<div class="form-group">
|
|
162
|
+
<label for="export-gap">Gap (mm):</label>
|
|
163
|
+
<input type="number" id="export-gap" value="0" step="0.1">
|
|
164
|
+
</div>
|
|
165
|
+
<div class="form-group">
|
|
166
|
+
<label>Margins (mm):</label>
|
|
167
|
+
<div class="input-group">
|
|
168
|
+
<input type="number" id="export-margin-x" value="2" step="0.1" placeholder="X">
|
|
169
|
+
<input type="number" id="export-margin-y" value="2" step="0.1" placeholder="Y">
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
<div class="modal-actions">
|
|
174
|
+
<button id="export-cancel-btn" class="btn secondary">Cancel</button>
|
|
175
|
+
<button id="export-confirm-btn" class="btn primary">Export</button>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
|
181
|
+
</body>
|
|
182
|
+
</html>
|
decksmith/image_ops.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains image processing operations (filters) used by CardBuilder.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Tuple
|
|
6
|
+
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ImageOps:
|
|
11
|
+
"""
|
|
12
|
+
A class to handle image processing operations.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def apply_filters(img: Image.Image, filters: Dict[str, Any]) -> Image.Image:
|
|
17
|
+
"""
|
|
18
|
+
Applies a set of filters to an image.
|
|
19
|
+
Args:
|
|
20
|
+
img (Image.Image): The image to process.
|
|
21
|
+
filters (Dict[str, Any]): A dictionary of filters to apply.
|
|
22
|
+
Returns:
|
|
23
|
+
Image.Image: The processed image.
|
|
24
|
+
"""
|
|
25
|
+
for filter_name, filter_value in filters.items():
|
|
26
|
+
filter_method_name = f"_filter_{filter_name}"
|
|
27
|
+
if hasattr(ImageOps, filter_method_name):
|
|
28
|
+
filter_method = getattr(ImageOps, filter_method_name)
|
|
29
|
+
img = filter_method(img, filter_value)
|
|
30
|
+
return img
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _filter_crop(img: Image.Image, crop_values: List[int]) -> Image.Image:
|
|
34
|
+
return img.crop(tuple(crop_values))
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _filter_crop_top(img: Image.Image, value: int) -> Image.Image:
|
|
38
|
+
if value < 0:
|
|
39
|
+
img = img.convert("RGBA")
|
|
40
|
+
new_img = Image.new("RGBA", (img.width, img.height - value), (0, 0, 0, 0))
|
|
41
|
+
new_img.paste(img, (0, -value))
|
|
42
|
+
return new_img
|
|
43
|
+
return img.crop((0, value, img.width, img.height))
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def _filter_crop_bottom(img: Image.Image, value: int) -> Image.Image:
|
|
47
|
+
if value < 0:
|
|
48
|
+
img = img.convert("RGBA")
|
|
49
|
+
new_img = Image.new("RGBA", (img.width, img.height - value), (0, 0, 0, 0))
|
|
50
|
+
new_img.paste(img, (0, 0))
|
|
51
|
+
return new_img
|
|
52
|
+
return img.crop((0, 0, img.width, img.height - value))
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _filter_crop_left(img: Image.Image, value: int) -> Image.Image:
|
|
56
|
+
if value < 0:
|
|
57
|
+
img = img.convert("RGBA")
|
|
58
|
+
new_img = Image.new("RGBA", (img.width - value, img.height), (0, 0, 0, 0))
|
|
59
|
+
new_img.paste(img, (-value, 0))
|
|
60
|
+
return new_img
|
|
61
|
+
return img.crop((value, 0, img.width, img.height))
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _filter_crop_right(img: Image.Image, value: int) -> Image.Image:
|
|
65
|
+
if value < 0:
|
|
66
|
+
img = img.convert("RGBA")
|
|
67
|
+
new_img = Image.new("RGBA", (img.width - value, img.height), (0, 0, 0, 0))
|
|
68
|
+
new_img.paste(img, (0, 0))
|
|
69
|
+
return new_img
|
|
70
|
+
return img.crop((0, 0, img.width - value, img.height))
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _filter_crop_box(img: Image.Image, box: List[int]) -> Image.Image:
|
|
74
|
+
img = img.convert("RGBA")
|
|
75
|
+
position_horizontal, position_vertical, width, height = box
|
|
76
|
+
new_img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
|
77
|
+
source_left = max(0, position_horizontal)
|
|
78
|
+
source_top = max(0, position_vertical)
|
|
79
|
+
source_right = min(img.width, position_horizontal + width)
|
|
80
|
+
source_bottom = min(img.height, position_vertical + height)
|
|
81
|
+
if source_left < source_right and source_top < source_bottom:
|
|
82
|
+
source_width = source_right - source_left
|
|
83
|
+
source_height = source_bottom - source_top
|
|
84
|
+
src_img = img.crop(
|
|
85
|
+
(
|
|
86
|
+
source_left,
|
|
87
|
+
source_top,
|
|
88
|
+
source_left + source_width,
|
|
89
|
+
source_top + source_height,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
destination_horizontal = source_left - position_horizontal
|
|
93
|
+
destination_vertical = source_top - position_vertical
|
|
94
|
+
new_img.paste(src_img, (destination_horizontal, destination_vertical))
|
|
95
|
+
return new_img
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _filter_resize(img: Image.Image, size: Tuple[int, int]) -> Image.Image:
|
|
99
|
+
new_width, new_height = size
|
|
100
|
+
if new_width is None and new_height is None:
|
|
101
|
+
return img
|
|
102
|
+
if new_width is None or new_height is None:
|
|
103
|
+
original_width, original_height = img.size
|
|
104
|
+
aspect_ratio = original_width / float(original_height)
|
|
105
|
+
if new_width is None:
|
|
106
|
+
new_width = int(new_height * aspect_ratio)
|
|
107
|
+
else:
|
|
108
|
+
new_height = int(new_width / aspect_ratio)
|
|
109
|
+
return img.resize((new_width, new_height))
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _filter_rotate(img: Image.Image, angle: float) -> Image.Image:
|
|
113
|
+
return img.rotate(angle, expand=True)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _filter_flip(img: Image.Image, direction: str) -> Image.Image:
|
|
117
|
+
if direction == "horizontal":
|
|
118
|
+
return img.transpose(Image.FLIP_LEFT_RIGHT)
|
|
119
|
+
if direction == "vertical":
|
|
120
|
+
return img.transpose(Image.FLIP_TOP_BOTTOM)
|
|
121
|
+
return img
|
decksmith/logger.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module configures the logging for the application.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def setup_logging(log_file: str = "decksmith.log", level: int = logging.INFO):
|
|
10
|
+
"""
|
|
11
|
+
Sets up the logging configuration.
|
|
12
|
+
"""
|
|
13
|
+
# Create a custom logger
|
|
14
|
+
log = logging.getLogger("decksmith")
|
|
15
|
+
log.setLevel(level)
|
|
16
|
+
|
|
17
|
+
# Create handlers
|
|
18
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
|
19
|
+
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
20
|
+
console_handler.setLevel(level)
|
|
21
|
+
file_handler.setLevel(level)
|
|
22
|
+
|
|
23
|
+
# Create formatters and add it to handlers
|
|
24
|
+
console_format = logging.Formatter("%(message)s")
|
|
25
|
+
file_format = logging.Formatter(
|
|
26
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
27
|
+
)
|
|
28
|
+
console_handler.setFormatter(console_format)
|
|
29
|
+
file_handler.setFormatter(file_format)
|
|
30
|
+
|
|
31
|
+
# Add handlers to the logger
|
|
32
|
+
if not log.hasHandlers():
|
|
33
|
+
log.addHandler(console_handler)
|
|
34
|
+
log.addHandler(file_handler)
|
|
35
|
+
|
|
36
|
+
return log
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
logger = setup_logging()
|
decksmith/macro.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains logic for resolving macros in card specifications.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MacroResolver:
|
|
9
|
+
"""
|
|
10
|
+
A class to resolve macros in card specifications.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def resolve(spec: Dict[str, Any], row: Dict[str, Any]) -> Dict[str, Any]:
|
|
15
|
+
"""
|
|
16
|
+
Replaces %colname% macros in the card specification with values from the row.
|
|
17
|
+
Works recursively for nested structures.
|
|
18
|
+
Args:
|
|
19
|
+
spec (dict): The card specification.
|
|
20
|
+
row (dict): A dictionary representing a row from the CSV file.
|
|
21
|
+
Returns:
|
|
22
|
+
dict: The updated card specification with macros replaced.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def replace_in_value(value: Any) -> Any:
|
|
26
|
+
if isinstance(value, str):
|
|
27
|
+
stripped_value = value.strip()
|
|
28
|
+
# First, check for an exact macro match to preserve type
|
|
29
|
+
for key in row:
|
|
30
|
+
if stripped_value == f"%{key}%":
|
|
31
|
+
return row[key] # Return the raw value, preserving type
|
|
32
|
+
|
|
33
|
+
# If no exact match, perform standard string replacement for all macros
|
|
34
|
+
for key, val in row.items():
|
|
35
|
+
value = value.replace(f"%{key}%", str(val))
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
if isinstance(value, list):
|
|
39
|
+
return [replace_in_value(item) for item in value]
|
|
40
|
+
|
|
41
|
+
if isinstance(value, dict):
|
|
42
|
+
return {key: replace_in_value(item) for key, item in value.items()}
|
|
43
|
+
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
return replace_in_value(spec)
|
decksmith/main.py
CHANGED
|
@@ -3,29 +3,38 @@ This module provides a command-line tool for building decks of cards.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import shutil
|
|
6
|
+
import traceback
|
|
6
7
|
from importlib import resources
|
|
7
8
|
from pathlib import Path
|
|
8
|
-
import traceback
|
|
9
9
|
|
|
10
10
|
import click
|
|
11
|
+
|
|
11
12
|
from decksmith.deck_builder import DeckBuilder
|
|
12
13
|
from decksmith.export import PdfExporter
|
|
14
|
+
from decksmith.gui.app import main as gui_main
|
|
15
|
+
from decksmith.logger import logger
|
|
13
16
|
|
|
14
17
|
|
|
15
|
-
@click.group()
|
|
16
|
-
|
|
18
|
+
@click.group(invoke_without_command=True)
|
|
19
|
+
@click.option("--gui", is_flag=True, help="Launch the graphical user interface.")
|
|
20
|
+
@click.pass_context
|
|
21
|
+
def cli(ctx, gui):
|
|
17
22
|
"""A command-line tool for building decks of cards."""
|
|
23
|
+
if gui:
|
|
24
|
+
gui_main()
|
|
25
|
+
elif ctx.invoked_subcommand is None:
|
|
26
|
+
click.echo(ctx.get_help())
|
|
18
27
|
|
|
19
28
|
|
|
20
29
|
@cli.command()
|
|
21
30
|
def init():
|
|
22
|
-
"""Initializes a new project by creating deck.
|
|
23
|
-
if Path("deck.
|
|
31
|
+
"""Initializes a new project by creating deck.yaml and deck.csv."""
|
|
32
|
+
if Path("deck.yaml").exists() or Path("deck.csv").exists():
|
|
24
33
|
click.echo("(!) Project already initialized.")
|
|
25
34
|
return
|
|
26
35
|
|
|
27
|
-
with resources.path("decksmith.templates", "deck.
|
|
28
|
-
shutil.copy(template_path, "deck.
|
|
36
|
+
with resources.path("decksmith.templates", "deck.yaml") as template_path:
|
|
37
|
+
shutil.copy(template_path, "deck.yaml")
|
|
29
38
|
with resources.path("decksmith.templates", "deck.csv") as template_path:
|
|
30
39
|
shutil.copy(template_path, "deck.csv")
|
|
31
40
|
|
|
@@ -35,7 +44,7 @@ def init():
|
|
|
35
44
|
@cli.command(context_settings={"show_default": True})
|
|
36
45
|
@click.option("--output", default="output", help="The output directory for the deck.")
|
|
37
46
|
@click.option(
|
|
38
|
-
"--spec", default="deck.
|
|
47
|
+
"--spec", default="deck.yaml", help="The path to the deck specification file."
|
|
39
48
|
)
|
|
40
49
|
@click.option("--data", default="deck.csv", help="The path to the data file.")
|
|
41
50
|
@click.pass_context
|
|
@@ -44,7 +53,7 @@ def build(ctx, output, spec, data):
|
|
|
44
53
|
output_path = Path(output)
|
|
45
54
|
output_path.mkdir(exist_ok=True)
|
|
46
55
|
|
|
47
|
-
|
|
56
|
+
logger.info("(i) Building deck in %s...", output_path)
|
|
48
57
|
|
|
49
58
|
try:
|
|
50
59
|
spec_path = Path(spec)
|
|
@@ -55,8 +64,9 @@ def build(ctx, output, spec, data):
|
|
|
55
64
|
if not csv_path.exists():
|
|
56
65
|
source = ctx.get_parameter_source("data")
|
|
57
66
|
if source.name == "DEFAULT":
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
logger.info(
|
|
68
|
+
"(i) Building a single card deck because '%s' was not found",
|
|
69
|
+
csv_path,
|
|
60
70
|
)
|
|
61
71
|
csv_path = None
|
|
62
72
|
else:
|
|
@@ -69,14 +79,12 @@ def build(ctx, output, spec, data):
|
|
|
69
79
|
ctx.exit(1)
|
|
70
80
|
# pylint: disable=W0718
|
|
71
81
|
except Exception as exc:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
print(f"(x) Error building deck '{data}' from spec '{spec}':")
|
|
76
|
-
print(" " * 4 + f"{exc}")
|
|
82
|
+
logger.error("(x) Error building deck '%s' from spec '%s':", data, spec)
|
|
83
|
+
logger.error(" %s", exc)
|
|
84
|
+
logger.debug(traceback.format_exc())
|
|
77
85
|
ctx.exit(1)
|
|
78
86
|
|
|
79
|
-
|
|
87
|
+
logger.info("(✔) Deck built successfully.")
|
|
80
88
|
|
|
81
89
|
|
|
82
90
|
@cli.command(context_settings={"show_default": True})
|
|
@@ -123,16 +131,16 @@ def export(image_folder, output, page_size, width, height, gap, margins):
|
|
|
123
131
|
margins=margins,
|
|
124
132
|
)
|
|
125
133
|
exporter.export()
|
|
126
|
-
|
|
134
|
+
logger.info("(✔) Successfully exported PDF to %s", output)
|
|
127
135
|
except FileNotFoundError as exc:
|
|
128
|
-
|
|
136
|
+
logger.error("(x) %s", exc)
|
|
129
137
|
# pylint: disable=W0718
|
|
130
138
|
except Exception as exc:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
print(" " * 4 + f"{exc}")
|
|
139
|
+
logger.error("(x) Error exporting images to '%s':", output)
|
|
140
|
+
logger.error(" %s", exc)
|
|
141
|
+
logger.debug(traceback.format_exc())
|
|
135
142
|
|
|
136
143
|
|
|
137
144
|
if __name__ == "__main__":
|
|
145
|
+
# pylint: disable=no-value-for-parameter
|
|
138
146
|
cli()
|
decksmith/project.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module contains the ProjectManager class for managing decksmith projects.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProjectManager:
|
|
11
|
+
"""
|
|
12
|
+
A class to manage decksmith projects.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.working_dir: Optional[Path] = None
|
|
17
|
+
|
|
18
|
+
def set_working_dir(self, path: Path):
|
|
19
|
+
"""Sets the working directory."""
|
|
20
|
+
self.working_dir = path
|
|
21
|
+
|
|
22
|
+
def get_working_dir(self) -> Optional[Path]:
|
|
23
|
+
"""Returns the current working directory."""
|
|
24
|
+
return self.working_dir
|
|
25
|
+
|
|
26
|
+
def close_project(self):
|
|
27
|
+
"""Closes the current project."""
|
|
28
|
+
self.working_dir = None
|
|
29
|
+
|
|
30
|
+
def create_project(self, path: Path):
|
|
31
|
+
"""
|
|
32
|
+
Creates a new project at the specified path.
|
|
33
|
+
Args:
|
|
34
|
+
path (Path): The path to create the project in.
|
|
35
|
+
"""
|
|
36
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
# Copy templates
|
|
39
|
+
# Assuming templates are in decksmith/templates relative to this file
|
|
40
|
+
# But actually they are in decksmith/templates relative to the package root
|
|
41
|
+
# Let's use importlib.resources or relative path from __file__
|
|
42
|
+
# Since we are in decksmith/project.py, templates are in ../templates
|
|
43
|
+
template_dir = Path(__file__).parent / "templates"
|
|
44
|
+
|
|
45
|
+
if not (path / "deck.yaml").exists():
|
|
46
|
+
shutil.copy(template_dir / "deck.yaml", path / "deck.yaml")
|
|
47
|
+
|
|
48
|
+
if not (path / "deck.csv").exists():
|
|
49
|
+
shutil.copy(template_dir / "deck.csv", path / "deck.csv")
|
|
50
|
+
|
|
51
|
+
self.working_dir = path
|
|
52
|
+
|
|
53
|
+
def load_files(self) -> Dict[str, str]:
|
|
54
|
+
"""
|
|
55
|
+
Loads the deck.yaml and deck.csv files from the current project.
|
|
56
|
+
Returns:
|
|
57
|
+
Dict[str, str]: A dictionary containing the content of the files.
|
|
58
|
+
"""
|
|
59
|
+
if self.working_dir is None:
|
|
60
|
+
return {"yaml": "", "csv": ""}
|
|
61
|
+
|
|
62
|
+
yaml_path = self.working_dir / "deck.yaml"
|
|
63
|
+
csv_path = self.working_dir / "deck.csv"
|
|
64
|
+
|
|
65
|
+
template_dir = Path(__file__).parent / "templates"
|
|
66
|
+
yaml_template = template_dir / "deck.yaml"
|
|
67
|
+
csv_template = template_dir / "deck.csv"
|
|
68
|
+
|
|
69
|
+
data = {}
|
|
70
|
+
|
|
71
|
+
# Load YAML
|
|
72
|
+
if yaml_path.exists() and yaml_path.stat().st_size > 0:
|
|
73
|
+
with open(yaml_path, "r", encoding="utf-8") as yaml_file:
|
|
74
|
+
data["yaml"] = yaml_file.read()
|
|
75
|
+
elif yaml_template.exists():
|
|
76
|
+
with open(yaml_template, "r", encoding="utf-8") as yaml_template_file:
|
|
77
|
+
data["yaml"] = yaml_template_file.read()
|
|
78
|
+
else:
|
|
79
|
+
data["yaml"] = ""
|
|
80
|
+
|
|
81
|
+
# Load CSV
|
|
82
|
+
if csv_path.exists() and csv_path.stat().st_size > 0:
|
|
83
|
+
with open(csv_path, "r", encoding="utf-8") as csv_file:
|
|
84
|
+
data["csv"] = csv_file.read()
|
|
85
|
+
elif csv_template.exists():
|
|
86
|
+
with open(csv_template, "r", encoding="utf-8") as csv_template_file:
|
|
87
|
+
data["csv"] = csv_template_file.read()
|
|
88
|
+
else:
|
|
89
|
+
data["csv"] = ""
|
|
90
|
+
|
|
91
|
+
return data
|
|
92
|
+
|
|
93
|
+
def save_files(self, yaml_content: Optional[str], csv_content: Optional[str]):
|
|
94
|
+
"""
|
|
95
|
+
Saves the deck.yaml and deck.csv files to the current project.
|
|
96
|
+
Args:
|
|
97
|
+
yaml_content (Optional[str]): The content of the deck.yaml file.
|
|
98
|
+
csv_content (Optional[str]): The content of the deck.csv file.
|
|
99
|
+
"""
|
|
100
|
+
if self.working_dir is None:
|
|
101
|
+
raise ValueError("No project selected")
|
|
102
|
+
|
|
103
|
+
if yaml_content is not None:
|
|
104
|
+
with open(
|
|
105
|
+
self.working_dir / "deck.yaml", "w", encoding="utf-8"
|
|
106
|
+
) as yaml_file:
|
|
107
|
+
yaml_file.write(yaml_content.replace("\r\n", "\n"))
|
|
108
|
+
|
|
109
|
+
if csv_content is not None:
|
|
110
|
+
with open(self.working_dir / "deck.csv", "w", encoding="utf-8") as csv_file:
|
|
111
|
+
csv_file.write(csv_content.replace("\r\n", "\n"))
|