sqlbench 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.
- sqlbench/__init__.py +3 -0
- sqlbench/__main__.py +7 -0
- sqlbench/adapters.py +383 -0
- sqlbench/app.py +1398 -0
- sqlbench/database.py +215 -0
- sqlbench/dialogs/__init__.py +1 -0
- sqlbench/dialogs/connection_dialog.py +356 -0
- sqlbench/dialogs/regex_builder_dialog.py +542 -0
- sqlbench/tabs/__init__.py +1 -0
- sqlbench/tabs/spool_tab.py +572 -0
- sqlbench/tabs/sql_tab.py +1827 -0
- sqlbench-0.1.0.dist-info/METADATA +91 -0
- sqlbench-0.1.0.dist-info/RECORD +15 -0
- sqlbench-0.1.0.dist-info/WHEEL +4 -0
- sqlbench-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
"""Spool file utility tab."""
|
|
2
|
+
|
|
3
|
+
import tkinter as tk
|
|
4
|
+
from tkinter import ttk, messagebox, filedialog
|
|
5
|
+
import threading
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
import platform
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SpoolTab:
|
|
12
|
+
def __init__(self, parent, app, connection, conn_name, os_version):
|
|
13
|
+
self.app = app
|
|
14
|
+
self.connection = connection
|
|
15
|
+
self.conn_name = conn_name
|
|
16
|
+
self.os_version = os_version
|
|
17
|
+
self.frame = ttk.Frame(parent)
|
|
18
|
+
self.frame.pack(fill=tk.BOTH, expand=True)
|
|
19
|
+
self._running = False
|
|
20
|
+
self._current_spool_info = None # Store spool file info for PDF export
|
|
21
|
+
self._create_widgets()
|
|
22
|
+
|
|
23
|
+
def _create_widgets(self):
|
|
24
|
+
# Top controls
|
|
25
|
+
ctrl_frame = ttk.Frame(self.frame)
|
|
26
|
+
ctrl_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5)
|
|
27
|
+
|
|
28
|
+
ttk.Label(ctrl_frame, text="User:").pack(side=tk.LEFT, padx=(0, 5))
|
|
29
|
+
self.user_entry = ttk.Entry(ctrl_frame, width=15)
|
|
30
|
+
self.user_entry.pack(side=tk.LEFT, padx=(0, 10))
|
|
31
|
+
self.user_entry.insert(0, "*CURRENT")
|
|
32
|
+
|
|
33
|
+
self.refresh_btn = ttk.Button(ctrl_frame, text="Refresh", command=self._refresh_spool_files)
|
|
34
|
+
self.refresh_btn.pack(side=tk.LEFT, padx=2)
|
|
35
|
+
ttk.Button(ctrl_frame, text="View", command=self._view_spool_file).pack(side=tk.LEFT, padx=2)
|
|
36
|
+
ttk.Button(ctrl_frame, text="Delete", command=self._delete_spool_files).pack(side=tk.LEFT, padx=2)
|
|
37
|
+
|
|
38
|
+
# Connection info label
|
|
39
|
+
ttk.Label(ctrl_frame, text=f" [{self.conn_name}]").pack(side=tk.RIGHT, padx=5)
|
|
40
|
+
|
|
41
|
+
# Paned window for list and viewer
|
|
42
|
+
self.paned = ttk.PanedWindow(self.frame, orient=tk.HORIZONTAL)
|
|
43
|
+
self.paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
|
44
|
+
|
|
45
|
+
# Spool file list
|
|
46
|
+
list_frame = ttk.LabelFrame(self.paned, text="Spool Files")
|
|
47
|
+
self.paned.add(list_frame, weight=1)
|
|
48
|
+
|
|
49
|
+
columns = ("file", "user", "job", "filenumber", "status", "pages")
|
|
50
|
+
self.spool_tree = ttk.Treeview(list_frame, columns=columns, show="headings", selectmode="extended")
|
|
51
|
+
|
|
52
|
+
self.spool_tree.heading("file", text="File")
|
|
53
|
+
self.spool_tree.heading("user", text="User")
|
|
54
|
+
self.spool_tree.heading("job", text="Job")
|
|
55
|
+
self.spool_tree.heading("filenumber", text="File #")
|
|
56
|
+
self.spool_tree.heading("status", text="Status")
|
|
57
|
+
self.spool_tree.heading("pages", text="Pages")
|
|
58
|
+
|
|
59
|
+
self.spool_tree.column("file", width=75, minwidth=50)
|
|
60
|
+
self.spool_tree.column("user", width=50, minwidth=35)
|
|
61
|
+
self.spool_tree.column("job", width=180, minwidth=100)
|
|
62
|
+
self.spool_tree.column("filenumber", width=40, minwidth=30, anchor="e")
|
|
63
|
+
self.spool_tree.column("status", width=45, minwidth=35)
|
|
64
|
+
self.spool_tree.column("pages", width=40, minwidth=30, anchor="e")
|
|
65
|
+
|
|
66
|
+
spool_scroll = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.spool_tree.yview)
|
|
67
|
+
self.spool_tree.configure(yscrollcommand=spool_scroll.set)
|
|
68
|
+
|
|
69
|
+
spool_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
|
70
|
+
self.spool_tree.pack(fill=tk.BOTH, expand=True)
|
|
71
|
+
|
|
72
|
+
self.spool_tree.bind("<Double-1>", lambda e: self._view_spool_file())
|
|
73
|
+
|
|
74
|
+
# Viewer area
|
|
75
|
+
viewer_frame = ttk.LabelFrame(self.paned, text="Viewer")
|
|
76
|
+
self.paned.add(viewer_frame, weight=2)
|
|
77
|
+
|
|
78
|
+
# Viewer button bar
|
|
79
|
+
viewer_btn_frame = ttk.Frame(viewer_frame)
|
|
80
|
+
viewer_btn_frame.pack(side=tk.TOP, fill=tk.X, padx=2, pady=2)
|
|
81
|
+
|
|
82
|
+
self.save_pdf_btn = ttk.Button(viewer_btn_frame, text="Save PDF", command=self._save_pdf, state=tk.DISABLED)
|
|
83
|
+
self.save_pdf_btn.pack(side=tk.LEFT, padx=2)
|
|
84
|
+
self.print_btn = ttk.Button(viewer_btn_frame, text="Print", command=self._print_spool, state=tk.DISABLED)
|
|
85
|
+
self.print_btn.pack(side=tk.LEFT, padx=2)
|
|
86
|
+
|
|
87
|
+
self.viewer_text = tk.Text(viewer_frame, wrap=tk.NONE, font=("Courier", 10))
|
|
88
|
+
viewer_scroll_y = ttk.Scrollbar(viewer_frame, orient=tk.VERTICAL, command=self.viewer_text.yview)
|
|
89
|
+
viewer_scroll_x = ttk.Scrollbar(viewer_frame, orient=tk.HORIZONTAL, command=self.viewer_text.xview)
|
|
90
|
+
self.viewer_text.configure(yscrollcommand=viewer_scroll_y.set, xscrollcommand=viewer_scroll_x.set)
|
|
91
|
+
|
|
92
|
+
viewer_scroll_y.pack(side=tk.RIGHT, fill=tk.Y)
|
|
93
|
+
viewer_scroll_x.pack(side=tk.BOTTOM, fill=tk.X)
|
|
94
|
+
self.viewer_text.pack(fill=tk.BOTH, expand=True)
|
|
95
|
+
|
|
96
|
+
def _set_running(self, running):
|
|
97
|
+
"""Update UI state for running/not running."""
|
|
98
|
+
self._running = running
|
|
99
|
+
if running:
|
|
100
|
+
self.refresh_btn.config(state=tk.DISABLED)
|
|
101
|
+
self.app.root.config(cursor="watch")
|
|
102
|
+
else:
|
|
103
|
+
self.refresh_btn.config(state=tk.NORMAL)
|
|
104
|
+
self.app.root.config(cursor="")
|
|
105
|
+
|
|
106
|
+
def _refresh_spool_files(self):
|
|
107
|
+
if self._running:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
user = self.user_entry.get().strip().upper() or "*CURRENT"
|
|
111
|
+
self.user_entry.delete(0, tk.END)
|
|
112
|
+
self.user_entry.insert(0, user)
|
|
113
|
+
|
|
114
|
+
# Clear current list
|
|
115
|
+
self.spool_tree.delete(*self.spool_tree.get_children())
|
|
116
|
+
|
|
117
|
+
self._set_running(True)
|
|
118
|
+
self.app.statusbar.config(text=f"Loading spool files from {self.conn_name}...")
|
|
119
|
+
|
|
120
|
+
thread = threading.Thread(target=self._fetch_spool_files, args=(user,), daemon=True)
|
|
121
|
+
thread.start()
|
|
122
|
+
|
|
123
|
+
def _fetch_spool_files(self, user):
|
|
124
|
+
"""Fetch spool files in background thread."""
|
|
125
|
+
try:
|
|
126
|
+
cursor = self.connection.cursor()
|
|
127
|
+
|
|
128
|
+
sql = """
|
|
129
|
+
SELECT
|
|
130
|
+
SPOOLED_FILE_NAME,
|
|
131
|
+
USER_NAME,
|
|
132
|
+
JOB_NAME,
|
|
133
|
+
FILE_NUMBER,
|
|
134
|
+
STATUS,
|
|
135
|
+
TOTAL_PAGES
|
|
136
|
+
FROM QSYS2.OUTPUT_QUEUE_ENTRIES
|
|
137
|
+
WHERE USER_NAME = CASE WHEN ? = '*CURRENT' THEN USER ELSE ? END
|
|
138
|
+
ORDER BY CREATE_TIMESTAMP DESC
|
|
139
|
+
FETCH FIRST 100 ROWS ONLY
|
|
140
|
+
"""
|
|
141
|
+
cursor.execute(sql, (user, user))
|
|
142
|
+
rows = cursor.fetchall()
|
|
143
|
+
cursor.close()
|
|
144
|
+
|
|
145
|
+
self.app.root.after(0, self._display_spool_files, rows, user)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
self.app.root.after(0, self._spool_error, str(e))
|
|
148
|
+
finally:
|
|
149
|
+
self.app.root.after(0, self._set_running, False)
|
|
150
|
+
|
|
151
|
+
def _display_spool_files(self, rows, user):
|
|
152
|
+
"""Display spool files in treeview (called from main thread)."""
|
|
153
|
+
for row in rows:
|
|
154
|
+
clean_row = tuple(str(v) if v is not None else "" for v in row)
|
|
155
|
+
self.spool_tree.insert("", tk.END, values=clean_row)
|
|
156
|
+
self.app.statusbar.config(text=f"Loaded {len(rows)} spool files for {user} on {self.conn_name}")
|
|
157
|
+
|
|
158
|
+
def _spool_error(self, error):
|
|
159
|
+
"""Handle spool file errors."""
|
|
160
|
+
messagebox.showerror("Error", error)
|
|
161
|
+
|
|
162
|
+
def _view_spool_file(self):
|
|
163
|
+
selection = self.spool_tree.selection()
|
|
164
|
+
if not selection:
|
|
165
|
+
messagebox.showinfo("Select", "Please select a spool file to view.")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
item = self.spool_tree.item(selection[0])
|
|
169
|
+
values = item["values"]
|
|
170
|
+
file_name = values[0]
|
|
171
|
+
qualified_job = values[2] # Already in "number/user/name" format
|
|
172
|
+
file_number = values[3]
|
|
173
|
+
|
|
174
|
+
# Parse job name from qualified job (format: number/user/name)
|
|
175
|
+
job_parts = qualified_job.split("/")
|
|
176
|
+
job_name = job_parts[2] if len(job_parts) == 3 else qualified_job
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
cursor = self.connection.cursor()
|
|
180
|
+
|
|
181
|
+
# Get spool file attributes for proper page formatting
|
|
182
|
+
attr_sql = """
|
|
183
|
+
SELECT PAGE_LENGTH, PAGE_WIDTH, LPI, CPI
|
|
184
|
+
FROM QSYS2.OUTPUT_QUEUE_ENTRIES
|
|
185
|
+
WHERE JOB_NAME = ?
|
|
186
|
+
AND SPOOLED_FILE_NAME = ?
|
|
187
|
+
AND FILE_NUMBER = ?
|
|
188
|
+
"""
|
|
189
|
+
cursor.execute(attr_sql, (qualified_job, file_name, int(file_number)))
|
|
190
|
+
attr_row = cursor.fetchone()
|
|
191
|
+
|
|
192
|
+
page_length = 66 # Default
|
|
193
|
+
page_width = 132 # Default
|
|
194
|
+
if attr_row:
|
|
195
|
+
page_length = attr_row[0] or 66
|
|
196
|
+
page_width = attr_row[1] or 132
|
|
197
|
+
|
|
198
|
+
# Save info for PDF export
|
|
199
|
+
self._current_spool_info = {
|
|
200
|
+
"file_name": file_name,
|
|
201
|
+
"job_name": job_name,
|
|
202
|
+
"file_number": file_number,
|
|
203
|
+
"page_length": page_length,
|
|
204
|
+
"page_width": page_width
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# Read spool file content using SYSTOOLS.SPOOLED_FILE_DATA
|
|
208
|
+
sql = """
|
|
209
|
+
SELECT SPOOLED_DATA
|
|
210
|
+
FROM TABLE(SYSTOOLS.SPOOLED_FILE_DATA(
|
|
211
|
+
JOB_NAME => ?,
|
|
212
|
+
SPOOLED_FILE_NAME => ?,
|
|
213
|
+
SPOOLED_FILE_NUMBER => ?
|
|
214
|
+
))
|
|
215
|
+
"""
|
|
216
|
+
cursor.execute(sql, (qualified_job, file_name, int(file_number)))
|
|
217
|
+
|
|
218
|
+
self.viewer_text.delete("1.0", tk.END)
|
|
219
|
+
|
|
220
|
+
# Store raw lines for PDF generation
|
|
221
|
+
raw_lines = []
|
|
222
|
+
for row in cursor.fetchall():
|
|
223
|
+
line = row[0] if row[0] else ""
|
|
224
|
+
# Clean control characters but keep printable ASCII and common chars
|
|
225
|
+
clean_line = ''.join(c if (c.isprintable() or c in '\t') else ' ' for c in line)
|
|
226
|
+
raw_lines.append(clean_line)
|
|
227
|
+
self.viewer_text.insert(tk.END, clean_line + "\n")
|
|
228
|
+
|
|
229
|
+
# Store lines in spool info for PDF
|
|
230
|
+
self._current_spool_info["lines"] = raw_lines
|
|
231
|
+
|
|
232
|
+
cursor.close()
|
|
233
|
+
self._update_viewer_buttons()
|
|
234
|
+
self.app.statusbar.config(text=f"Loaded spool file {file_name} ({page_width}x{page_length}, {len(raw_lines)} lines) from {self.conn_name}")
|
|
235
|
+
except Exception as e:
|
|
236
|
+
messagebox.showerror("Error", f"Could not read spool file: {e}")
|
|
237
|
+
|
|
238
|
+
def _delete_spool_files(self):
|
|
239
|
+
"""Delete selected spool files."""
|
|
240
|
+
selection = self.spool_tree.selection()
|
|
241
|
+
if not selection:
|
|
242
|
+
messagebox.showinfo("Select", "Please select spool file(s) to delete.")
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
# Build list of files to delete
|
|
246
|
+
files_to_delete = []
|
|
247
|
+
for item_id in selection:
|
|
248
|
+
item = self.spool_tree.item(item_id)
|
|
249
|
+
values = item["values"]
|
|
250
|
+
files_to_delete.append({
|
|
251
|
+
"file_name": values[0],
|
|
252
|
+
"job": values[2],
|
|
253
|
+
"file_number": values[3]
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
# Confirmation
|
|
257
|
+
count = len(files_to_delete)
|
|
258
|
+
if count == 1:
|
|
259
|
+
msg = f"Delete spool file '{files_to_delete[0]['file_name']}'?"
|
|
260
|
+
else:
|
|
261
|
+
msg = f"Delete {count} selected spool files?"
|
|
262
|
+
|
|
263
|
+
if not messagebox.askyesno("Confirm Delete", msg):
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
# Delete in background thread
|
|
267
|
+
self._set_running(True)
|
|
268
|
+
self.app.statusbar.config(text=f"Deleting {count} spool file(s)...")
|
|
269
|
+
thread = threading.Thread(target=self._do_delete_spool_files, args=(files_to_delete,), daemon=True)
|
|
270
|
+
thread.start()
|
|
271
|
+
|
|
272
|
+
def _do_delete_spool_files(self, files_to_delete):
|
|
273
|
+
"""Delete spool files in background thread."""
|
|
274
|
+
deleted = 0
|
|
275
|
+
errors = []
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
cursor = self.connection.cursor()
|
|
279
|
+
|
|
280
|
+
for f in files_to_delete:
|
|
281
|
+
try:
|
|
282
|
+
# Parse job name (format: number/user/name)
|
|
283
|
+
job_parts = f["job"].split("/")
|
|
284
|
+
if len(job_parts) == 3:
|
|
285
|
+
job_number, job_user, job_name = job_parts
|
|
286
|
+
else:
|
|
287
|
+
job_name = f["job"]
|
|
288
|
+
job_user = "*N"
|
|
289
|
+
job_number = "*N"
|
|
290
|
+
|
|
291
|
+
# Build DLTSPLF command
|
|
292
|
+
cmd = f"DLTSPLF FILE({f['file_name']}) JOB({job_number}/{job_user}/{job_name}) SPLNBR({f['file_number']})"
|
|
293
|
+
|
|
294
|
+
cursor.execute("CALL QSYS2.QCMDEXC(?)", (cmd,))
|
|
295
|
+
deleted += 1
|
|
296
|
+
except Exception as e:
|
|
297
|
+
errors.append(f"{f['file_name']}: {e}")
|
|
298
|
+
|
|
299
|
+
cursor.close()
|
|
300
|
+
except Exception as e:
|
|
301
|
+
errors.append(str(e))
|
|
302
|
+
|
|
303
|
+
# Update UI on main thread
|
|
304
|
+
self.app.root.after(0, self._delete_complete, deleted, errors)
|
|
305
|
+
|
|
306
|
+
def _delete_complete(self, deleted, errors):
|
|
307
|
+
"""Handle delete completion on main thread."""
|
|
308
|
+
self._set_running(False)
|
|
309
|
+
|
|
310
|
+
if errors:
|
|
311
|
+
error_msg = "\n".join(errors[:5]) # Show first 5 errors
|
|
312
|
+
if len(errors) > 5:
|
|
313
|
+
error_msg += f"\n... and {len(errors) - 5} more errors"
|
|
314
|
+
messagebox.showerror("Delete Errors", f"Deleted {deleted} file(s).\n\nErrors:\n{error_msg}")
|
|
315
|
+
else:
|
|
316
|
+
self.app.statusbar.config(text=f"Deleted {deleted} spool file(s) from {self.conn_name}")
|
|
317
|
+
|
|
318
|
+
# Refresh list
|
|
319
|
+
self._refresh_spool_files()
|
|
320
|
+
|
|
321
|
+
def _generate_pdf(self, content, file_path):
|
|
322
|
+
"""Generate a PDF from content using spool file attributes."""
|
|
323
|
+
from reportlab.lib.pagesizes import letter, landscape
|
|
324
|
+
from reportlab.lib.units import inch
|
|
325
|
+
from reportlab.pdfgen import canvas
|
|
326
|
+
|
|
327
|
+
# Get spool file attributes and stored lines
|
|
328
|
+
spool_page_length = 66 # Default lines per page
|
|
329
|
+
spool_page_width = 132 # Default chars per line
|
|
330
|
+
lines = None
|
|
331
|
+
|
|
332
|
+
if self._current_spool_info:
|
|
333
|
+
spool_page_length = self._current_spool_info.get("page_length", 66)
|
|
334
|
+
spool_page_width = self._current_spool_info.get("page_width", 132)
|
|
335
|
+
lines = self._current_spool_info.get("lines")
|
|
336
|
+
|
|
337
|
+
# Fall back to parsing content if no stored lines
|
|
338
|
+
if not lines:
|
|
339
|
+
lines = content.split("\n")
|
|
340
|
+
|
|
341
|
+
# Use landscape if spool file is wider than 80 characters
|
|
342
|
+
if spool_page_width > 80:
|
|
343
|
+
pagesize = landscape(letter)
|
|
344
|
+
font_size = 6.5 # Smaller to fit 132 chars
|
|
345
|
+
else:
|
|
346
|
+
pagesize = letter
|
|
347
|
+
font_size = 10
|
|
348
|
+
|
|
349
|
+
c = canvas.Canvas(file_path, pagesize=pagesize)
|
|
350
|
+
width, height = pagesize
|
|
351
|
+
|
|
352
|
+
# Calculate margins
|
|
353
|
+
left_margin = 0.3 * inch
|
|
354
|
+
top_margin = 0.3 * inch
|
|
355
|
+
bottom_margin = 0.3 * inch
|
|
356
|
+
usable_height = height - top_margin - bottom_margin
|
|
357
|
+
|
|
358
|
+
# Calculate line height to fit spool_page_length lines on the page
|
|
359
|
+
line_height = usable_height / spool_page_length
|
|
360
|
+
|
|
361
|
+
# Starting y position (from top of usable area)
|
|
362
|
+
start_y = height - top_margin - font_size
|
|
363
|
+
|
|
364
|
+
c.setFont("Courier", font_size)
|
|
365
|
+
y = start_y
|
|
366
|
+
line_on_page = 0
|
|
367
|
+
|
|
368
|
+
for line in lines:
|
|
369
|
+
# Check if we've reached the spool file's page length
|
|
370
|
+
if line_on_page >= spool_page_length:
|
|
371
|
+
c.showPage()
|
|
372
|
+
c.setFont("Courier", font_size)
|
|
373
|
+
y = start_y
|
|
374
|
+
line_on_page = 0
|
|
375
|
+
|
|
376
|
+
# Truncate long lines to page width
|
|
377
|
+
if len(line) > spool_page_width:
|
|
378
|
+
line = line[:spool_page_width]
|
|
379
|
+
|
|
380
|
+
c.drawString(left_margin, y, line)
|
|
381
|
+
y -= line_height
|
|
382
|
+
line_on_page += 1
|
|
383
|
+
|
|
384
|
+
c.save()
|
|
385
|
+
return True
|
|
386
|
+
|
|
387
|
+
def _save_pdf(self):
|
|
388
|
+
"""Save the current viewer content as PDF."""
|
|
389
|
+
content = self.viewer_text.get("1.0", tk.END).strip()
|
|
390
|
+
if not content:
|
|
391
|
+
messagebox.showwarning("Empty", "No spool file content to save.")
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
# Generate filename from job name and file number
|
|
395
|
+
if self._current_spool_info:
|
|
396
|
+
job_name = self._current_spool_info.get("job_name", "spool")
|
|
397
|
+
file_number = self._current_spool_info.get("file_number", "0")
|
|
398
|
+
default_name = f"{job_name}_{file_number}.pdf"
|
|
399
|
+
else:
|
|
400
|
+
default_name = "spoolfile.pdf"
|
|
401
|
+
|
|
402
|
+
file_path = filedialog.asksaveasfilename(
|
|
403
|
+
defaultextension=".pdf",
|
|
404
|
+
filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")],
|
|
405
|
+
initialfile=default_name
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if not file_path:
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
self._generate_pdf(content, file_path)
|
|
413
|
+
self.app.statusbar.config(text=f"Saved PDF: {file_path}")
|
|
414
|
+
if messagebox.askyesno("Saved", f"PDF saved to:\n{file_path}\n\nOpen the PDF now?"):
|
|
415
|
+
self._open_file(file_path)
|
|
416
|
+
except ImportError:
|
|
417
|
+
messagebox.showerror("Error", "reportlab not installed. Run: pip install reportlab")
|
|
418
|
+
except Exception as e:
|
|
419
|
+
messagebox.showerror("Error", f"Could not save PDF: {e}")
|
|
420
|
+
|
|
421
|
+
def _open_file(self, file_path):
|
|
422
|
+
"""Open file with system default application."""
|
|
423
|
+
try:
|
|
424
|
+
system = platform.system()
|
|
425
|
+
if system == "Darwin":
|
|
426
|
+
subprocess.run(["open", file_path], check=True)
|
|
427
|
+
elif system == "Windows":
|
|
428
|
+
import os
|
|
429
|
+
os.startfile(file_path)
|
|
430
|
+
else: # Linux and others
|
|
431
|
+
subprocess.run(["xdg-open", file_path], check=True)
|
|
432
|
+
except Exception as e:
|
|
433
|
+
messagebox.showerror("Error", f"Could not open file: {e}")
|
|
434
|
+
|
|
435
|
+
def _update_viewer_buttons(self):
|
|
436
|
+
"""Enable/disable viewer buttons based on content."""
|
|
437
|
+
content = self.viewer_text.get("1.0", tk.END).strip()
|
|
438
|
+
state = tk.NORMAL if content else tk.DISABLED
|
|
439
|
+
self.save_pdf_btn.config(state=state)
|
|
440
|
+
self.print_btn.config(state=state)
|
|
441
|
+
|
|
442
|
+
def _print_spool(self):
|
|
443
|
+
"""Print the current viewer content."""
|
|
444
|
+
content = self.viewer_text.get("1.0", tk.END).strip()
|
|
445
|
+
if not content:
|
|
446
|
+
messagebox.showwarning("Empty", "No spool file content to print.")
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
# Show print dialog
|
|
450
|
+
self._show_print_dialog(content)
|
|
451
|
+
|
|
452
|
+
def _get_printers(self):
|
|
453
|
+
"""Get list of available printers."""
|
|
454
|
+
printers = []
|
|
455
|
+
default = None
|
|
456
|
+
system = platform.system()
|
|
457
|
+
try:
|
|
458
|
+
if system in ("Linux", "Darwin"):
|
|
459
|
+
# Use lpstat to get printer list
|
|
460
|
+
result = subprocess.run(["lpstat", "-a"], capture_output=True, text=True)
|
|
461
|
+
if result.returncode == 0:
|
|
462
|
+
for line in result.stdout.strip().split("\n"):
|
|
463
|
+
if line:
|
|
464
|
+
# Format: "printer_name accepting requests..."
|
|
465
|
+
printer = line.split()[0]
|
|
466
|
+
printers.append(printer)
|
|
467
|
+
# Get default printer
|
|
468
|
+
result = subprocess.run(["lpstat", "-d"], capture_output=True, text=True)
|
|
469
|
+
if result.returncode == 0 and ":" in result.stdout:
|
|
470
|
+
default = result.stdout.split(":")[1].strip()
|
|
471
|
+
elif system == "Windows":
|
|
472
|
+
# Use PowerShell to get printers (more reliable than wmic)
|
|
473
|
+
result = subprocess.run(
|
|
474
|
+
["powershell", "-Command", "Get-Printer | Select-Object -ExpandProperty Name"],
|
|
475
|
+
capture_output=True, text=True, creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0
|
|
476
|
+
)
|
|
477
|
+
if result.returncode == 0:
|
|
478
|
+
for line in result.stdout.strip().split("\n"):
|
|
479
|
+
if line.strip():
|
|
480
|
+
printers.append(line.strip())
|
|
481
|
+
# Get default printer
|
|
482
|
+
result = subprocess.run(
|
|
483
|
+
["powershell", "-Command", "(Get-WmiObject -Query \"SELECT * FROM Win32_Printer WHERE Default=$true\").Name"],
|
|
484
|
+
capture_output=True, text=True, creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0
|
|
485
|
+
)
|
|
486
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
487
|
+
default = result.stdout.strip()
|
|
488
|
+
except Exception:
|
|
489
|
+
pass
|
|
490
|
+
return printers, default
|
|
491
|
+
|
|
492
|
+
def _show_print_dialog(self, content):
|
|
493
|
+
"""Show a print dialog to select printer."""
|
|
494
|
+
printers, default = self._get_printers()
|
|
495
|
+
|
|
496
|
+
dialog = tk.Toplevel(self.frame)
|
|
497
|
+
dialog.title("Print")
|
|
498
|
+
dialog.geometry("350x200")
|
|
499
|
+
dialog.transient(self.frame.winfo_toplevel())
|
|
500
|
+
dialog.grab_set()
|
|
501
|
+
|
|
502
|
+
# Printer selection
|
|
503
|
+
ttk.Label(dialog, text="Select Printer:").pack(anchor=tk.W, padx=10, pady=(10, 5))
|
|
504
|
+
|
|
505
|
+
printer_var = tk.StringVar()
|
|
506
|
+
if printers:
|
|
507
|
+
printer_combo = ttk.Combobox(dialog, textvariable=printer_var, values=printers, state="readonly", width=40)
|
|
508
|
+
printer_combo.pack(padx=10, pady=5)
|
|
509
|
+
if default and default in printers:
|
|
510
|
+
printer_combo.set(default)
|
|
511
|
+
elif printers:
|
|
512
|
+
printer_combo.set(printers[0])
|
|
513
|
+
else:
|
|
514
|
+
ttk.Label(dialog, text="No printers found. Using system default.").pack(padx=10, pady=5)
|
|
515
|
+
|
|
516
|
+
# Copies
|
|
517
|
+
copies_frame = ttk.Frame(dialog)
|
|
518
|
+
copies_frame.pack(fill=tk.X, padx=10, pady=10)
|
|
519
|
+
ttk.Label(copies_frame, text="Copies:").pack(side=tk.LEFT)
|
|
520
|
+
copies_var = tk.StringVar(value="1")
|
|
521
|
+
copies_spin = ttk.Spinbox(copies_frame, from_=1, to=99, width=5, textvariable=copies_var)
|
|
522
|
+
copies_spin.pack(side=tk.LEFT, padx=5)
|
|
523
|
+
|
|
524
|
+
# Buttons
|
|
525
|
+
btn_frame = ttk.Frame(dialog)
|
|
526
|
+
btn_frame.pack(fill=tk.X, padx=10, pady=20)
|
|
527
|
+
|
|
528
|
+
def do_print():
|
|
529
|
+
dialog.destroy()
|
|
530
|
+
self._send_to_printer(content, printer_var.get() if printers else None, int(copies_var.get()))
|
|
531
|
+
|
|
532
|
+
ttk.Button(btn_frame, text="Print", command=do_print).pack(side=tk.RIGHT, padx=5)
|
|
533
|
+
ttk.Button(btn_frame, text="Cancel", command=dialog.destroy).pack(side=tk.RIGHT, padx=5)
|
|
534
|
+
|
|
535
|
+
def _send_to_printer(self, content, printer, copies):
|
|
536
|
+
"""Send content to the specified printer by generating a PDF first."""
|
|
537
|
+
try:
|
|
538
|
+
# Generate PDF to temp file
|
|
539
|
+
temp_path = tempfile.mktemp(suffix='.pdf')
|
|
540
|
+
self._generate_pdf(content, temp_path)
|
|
541
|
+
|
|
542
|
+
system = platform.system()
|
|
543
|
+
if system in ("Linux", "Darwin"):
|
|
544
|
+
cmd = ["lp", "-n", str(copies)]
|
|
545
|
+
if printer:
|
|
546
|
+
cmd.extend(["-d", printer])
|
|
547
|
+
cmd.append(temp_path)
|
|
548
|
+
subprocess.run(cmd, check=True)
|
|
549
|
+
self.app.statusbar.config(text=f"Sent to printer: {printer or 'default'}")
|
|
550
|
+
elif system == "Windows":
|
|
551
|
+
import os
|
|
552
|
+
# Print PDF using default handler
|
|
553
|
+
for _ in range(copies):
|
|
554
|
+
os.startfile(temp_path, "print")
|
|
555
|
+
self.app.statusbar.config(text=f"Sent to printer: {printer or 'default'}")
|
|
556
|
+
except ImportError:
|
|
557
|
+
messagebox.showerror("Error", "reportlab not installed. Run: pip install reportlab")
|
|
558
|
+
except FileNotFoundError:
|
|
559
|
+
messagebox.showerror("Error", "Print command not found.\nEnsure CUPS (Linux/Mac) is available.")
|
|
560
|
+
except subprocess.CalledProcessError as e:
|
|
561
|
+
messagebox.showerror("Error", f"Print failed: {e}")
|
|
562
|
+
except Exception as e:
|
|
563
|
+
messagebox.showerror("Error", f"Could not print: {e}")
|
|
564
|
+
|
|
565
|
+
def get_user(self):
|
|
566
|
+
"""Get current user filter."""
|
|
567
|
+
return self.user_entry.get().strip()
|
|
568
|
+
|
|
569
|
+
def set_user(self, user):
|
|
570
|
+
"""Set user filter."""
|
|
571
|
+
self.user_entry.delete(0, tk.END)
|
|
572
|
+
self.user_entry.insert(0, user)
|