- Fixed network printer enumeration (PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS) - Added printer name truncation to 20 chars with full name mapping - Implemented silent PDF printing using SumatraPDF with landscape orientation - Added auto-dismiss for success popup (3 seconds) - Bundled SumatraPDF inside executable for portable single-file deployment - Updated build script to embed SumatraPDF - Added setup_sumatra.ps1 for downloading SumatraPDF portable - Added DEPLOYMENT.md documentation
457 lines
16 KiB
Python
457 lines
16 KiB
Python
"""
|
|
Label Printer GUI Application using Kivy
|
|
Simplified mobile-friendly interface for printing labels.
|
|
"""
|
|
|
|
from kivy.app import App
|
|
from kivy.uix.boxlayout import BoxLayout
|
|
from kivy.uix.gridlayout import GridLayout
|
|
from kivy.uix.label import Label
|
|
from kivy.uix.textinput import TextInput
|
|
from kivy.uix.button import Button
|
|
from kivy.uix.spinner import Spinner
|
|
from kivy.uix.scrollview import ScrollView
|
|
from kivy.uix.popup import Popup
|
|
from kivy.core.window import Window
|
|
from kivy.uix.image import Image as KivyImage
|
|
from kivy.graphics import Color, Rectangle
|
|
|
|
import os
|
|
import threading
|
|
import platform
|
|
import time
|
|
import datetime
|
|
import glob
|
|
from print_label import print_label_standalone, get_available_printers
|
|
from kivy.clock import Clock
|
|
|
|
# Set window size - portrait/phone dimensions (375x667 like iPhone)
|
|
# Adjusted to be slightly wider for touch-friendly UI
|
|
Window.size = (420, 700)
|
|
|
|
|
|
|
|
|
|
|
|
class LabelPrinterApp(App):
|
|
"""Simplified Kivy application for label printing"""
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
# Build printer display names and mapping to full names
|
|
full_printers = get_available_printers()
|
|
self.printer_display_map = {} # display_name -> full_name
|
|
self.available_printers = []
|
|
for full_name in full_printers:
|
|
display_name = self._shorten_printer_name(full_name)
|
|
# Ensure unique display names
|
|
if display_name in self.printer_display_map:
|
|
display_name = full_name[:20]
|
|
self.printer_display_map[display_name] = full_name
|
|
self.available_printers.append(display_name)
|
|
# Clean old PDF backup files on startup
|
|
self.cleanup_old_pdfs()
|
|
# Clean old log files on startup
|
|
self.cleanup_old_logs()
|
|
|
|
def _shorten_printer_name(self, name, max_len=20):
|
|
"""Shorten printer name for display (max 20 chars).
|
|
For network printers like \\\\server\\printer, show just the printer part."""
|
|
if name.startswith('\\\\'):
|
|
# Network printer: \\server\printer -> extract printer name
|
|
parts = name.strip('\\').split('\\')
|
|
if len(parts) >= 2:
|
|
short = parts[-1] # Just the printer name
|
|
else:
|
|
short = name
|
|
else:
|
|
short = name
|
|
# Truncate to max_len
|
|
if len(short) > max_len:
|
|
short = short[:max_len]
|
|
return short
|
|
|
|
def _get_full_printer_name(self, display_name):
|
|
"""Resolve display name back to full printer name for printing."""
|
|
return self.printer_display_map.get(display_name, display_name)
|
|
|
|
def cleanup_old_pdfs(self, days=5):
|
|
"""
|
|
Delete PDF files older than specified days from pdf_backup folder.
|
|
|
|
Args:
|
|
days (int): Delete files older than this many days (default: 5)
|
|
"""
|
|
pdf_backup_dir = 'pdf_backup'
|
|
|
|
# Create folder if it doesn't exist
|
|
if not os.path.exists(pdf_backup_dir):
|
|
os.makedirs(pdf_backup_dir, exist_ok=True)
|
|
return
|
|
|
|
try:
|
|
current_time = time.time()
|
|
cutoff_time = current_time - (days * 24 * 3600) # Convert days to seconds
|
|
|
|
for filename in os.listdir(pdf_backup_dir):
|
|
file_path = os.path.join(pdf_backup_dir, filename)
|
|
|
|
# Only process PDF files
|
|
if not filename.endswith('.pdf'):
|
|
continue
|
|
|
|
# Check if file is older than cutoff
|
|
if os.path.isfile(file_path):
|
|
file_mtime = os.path.getmtime(file_path)
|
|
if file_mtime < cutoff_time:
|
|
try:
|
|
os.remove(file_path)
|
|
print(f"Deleted old PDF: {filename}")
|
|
except Exception as e:
|
|
print(f"Failed to delete {filename}: {e}")
|
|
except Exception as e:
|
|
print(f"Error during PDF cleanup: {e}")
|
|
|
|
def cleanup_old_logs(self, days=5):
|
|
"""
|
|
Delete log files older than specified days from logs folder.
|
|
|
|
Args:
|
|
days (int): Delete files older than this many days (default: 5)
|
|
"""
|
|
logs_dir = 'logs'
|
|
|
|
# Create folder if it doesn't exist
|
|
if not os.path.exists(logs_dir):
|
|
os.makedirs(logs_dir, exist_ok=True)
|
|
return
|
|
|
|
try:
|
|
current_time = time.time()
|
|
cutoff_time = current_time - (days * 24 * 3600) # Convert days to seconds
|
|
|
|
for filename in os.listdir(logs_dir):
|
|
file_path = os.path.join(logs_dir, filename)
|
|
|
|
# Process both .log and .csv files
|
|
if not (filename.endswith('.log') or filename.endswith('.csv')):
|
|
continue
|
|
|
|
# Check if file is older than cutoff
|
|
if os.path.isfile(file_path):
|
|
file_mtime = os.path.getmtime(file_path)
|
|
if file_mtime < cutoff_time:
|
|
try:
|
|
os.remove(file_path)
|
|
print(f"Deleted old log: {filename}")
|
|
except Exception as e:
|
|
print(f"Failed to delete {filename}: {e}")
|
|
except Exception as e:
|
|
print(f"Error during log cleanup: {e}")
|
|
|
|
def log_print_action(self, sap_nr, quantity, cable_id, printer, pdf_filename, success):
|
|
"""
|
|
Log the print action to a CSV log file (table format).
|
|
|
|
Args:
|
|
sap_nr (str): SAP article number
|
|
quantity (str): Quantity value
|
|
cable_id (str): Cable ID
|
|
printer (str): Printer name
|
|
pdf_filename (str): Path to the generated PDF file
|
|
success (bool): Whether the print was successful
|
|
"""
|
|
logs_dir = 'logs'
|
|
|
|
# Create logs folder if it doesn't exist
|
|
os.makedirs(logs_dir, exist_ok=True)
|
|
|
|
try:
|
|
# Create log filename with date
|
|
log_date = datetime.datetime.now().strftime("%Y%m%d")
|
|
log_filename = os.path.join(logs_dir, f"print_log_{log_date}.csv")
|
|
|
|
# Create log entry values
|
|
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
status = "SUCCESS" if success else "FAILED"
|
|
|
|
# Escape CSV values (handle commas and quotes)
|
|
def escape_csv(value):
|
|
"""Escape CSV special characters"""
|
|
value = str(value)
|
|
if ',' in value or '"' in value or '\n' in value:
|
|
value = '"' + value.replace('"', '""') + '"'
|
|
return value
|
|
|
|
# Create CSV line
|
|
log_line = (
|
|
f"{timestamp},{status},"
|
|
f"{escape_csv(sap_nr)},"
|
|
f"{escape_csv(quantity)},"
|
|
f"{escape_csv(cable_id)},"
|
|
f"{escape_csv(printer)},"
|
|
f"{escape_csv(pdf_filename)}\n"
|
|
)
|
|
|
|
# Check if file exists to add header on first entry
|
|
file_exists = os.path.isfile(log_filename)
|
|
|
|
# Write to log file
|
|
with open(log_filename, 'a', encoding='utf-8') as f:
|
|
# Add header if file is new
|
|
if not file_exists:
|
|
f.write("Timestamp,Status,SAP-Nr,Quantity,Cable ID,Printer,PDF File\n")
|
|
# Append log entry
|
|
f.write(log_line)
|
|
|
|
print(f"Log entry saved to: {log_filename}")
|
|
except Exception as e:
|
|
print(f"Error saving log entry: {e}")
|
|
|
|
def build(self):
|
|
"""Build the simplified single-column UI"""
|
|
self.title = "Label Printing"
|
|
|
|
# Main container - single column layout
|
|
main_layout = BoxLayout(orientation='vertical', spacing=8, padding=12)
|
|
|
|
# Title
|
|
title = Label(
|
|
text='[b]Label Printing[/b]',
|
|
markup=True,
|
|
size_hint_y=0.08,
|
|
font_size='18sp',
|
|
color=(1, 1, 1, 1)
|
|
)
|
|
main_layout.add_widget(title)
|
|
|
|
# Scroll view for form fields
|
|
scroll = ScrollView(size_hint_y=0.75)
|
|
form_layout = GridLayout(cols=1, spacing=8, size_hint_y=None, padding=8)
|
|
form_layout.bind(minimum_height=form_layout.setter('height'))
|
|
|
|
# SAP-Nr. Articol
|
|
sap_label = Label(
|
|
text='SAP-Nr. Articol:',
|
|
size_hint_y=None,
|
|
height=30,
|
|
font_size='12sp'
|
|
)
|
|
form_layout.add_widget(sap_label)
|
|
|
|
self.sap_input = TextInput(
|
|
multiline=False,
|
|
size_hint_y=None,
|
|
height=45,
|
|
font_size='14sp',
|
|
background_color=(0.95, 0.95, 0.95, 1),
|
|
padding=(10, 10)
|
|
)
|
|
self.sap_input.bind(text=self.on_sap_text_change)
|
|
form_layout.add_widget(self.sap_input)
|
|
|
|
# Cantitate
|
|
qty_label = Label(
|
|
text='Cantitate:',
|
|
size_hint_y=None,
|
|
height=30,
|
|
font_size='12sp'
|
|
)
|
|
form_layout.add_widget(qty_label)
|
|
|
|
self.qty_input = TextInput(
|
|
multiline=False,
|
|
size_hint_y=None,
|
|
height=45,
|
|
font_size='14sp',
|
|
background_color=(0.95, 0.95, 0.95, 1),
|
|
padding=(10, 10),
|
|
input_filter='int' # Only allow numbers
|
|
)
|
|
self.qty_input.bind(text=self.on_qty_text_change)
|
|
form_layout.add_widget(self.qty_input)
|
|
|
|
# ID rola cablu
|
|
cable_id_label = Label(
|
|
text='ID rola cablu:',
|
|
size_hint_y=None,
|
|
height=30,
|
|
font_size='12sp'
|
|
)
|
|
form_layout.add_widget(cable_id_label)
|
|
|
|
self.cable_id_input = TextInput(
|
|
multiline=False,
|
|
size_hint_y=None,
|
|
height=45,
|
|
font_size='14sp',
|
|
background_color=(0.95, 0.95, 0.95, 1),
|
|
padding=(10, 10)
|
|
)
|
|
self.cable_id_input.bind(text=self.on_cable_id_text_change)
|
|
form_layout.add_widget(self.cable_id_input)
|
|
|
|
# Printer selection
|
|
printer_label = Label(
|
|
text='Select Printer:',
|
|
size_hint_y=None,
|
|
height=30,
|
|
font_size='12sp'
|
|
)
|
|
form_layout.add_widget(printer_label)
|
|
|
|
printer_spinner = Spinner(
|
|
text=self.available_printers[0] if self.available_printers else "No Printers",
|
|
values=self.available_printers,
|
|
size_hint_y=None,
|
|
height=45,
|
|
font_size='12sp',
|
|
sync_height=True,
|
|
)
|
|
self.printer_spinner = printer_spinner
|
|
form_layout.add_widget(printer_spinner)
|
|
|
|
scroll.add_widget(form_layout)
|
|
main_layout.add_widget(scroll)
|
|
|
|
# Print button
|
|
print_button = Button(
|
|
text='PRINT LABEL',
|
|
size_hint_y=0.15,
|
|
font_size='14sp',
|
|
background_color=(0.2, 0.6, 0.2, 1),
|
|
background_normal='',
|
|
bold=True
|
|
)
|
|
print_button.bind(on_press=self.print_label)
|
|
main_layout.add_widget(print_button)
|
|
|
|
return main_layout
|
|
|
|
def on_sap_text_change(self, instance, value):
|
|
"""Limit SAP input to 25 characters"""
|
|
if len(value) > 25:
|
|
self.sap_input.text = value[:25]
|
|
|
|
def on_qty_text_change(self, instance, value):
|
|
"""Limit Quantity input to 25 characters"""
|
|
if len(value) > 25:
|
|
self.qty_input.text = value[:25]
|
|
|
|
def on_cable_id_text_change(self, instance, value):
|
|
"""Limit Cable ID input to 25 characters"""
|
|
if len(value) > 25:
|
|
self.cable_id_input.text = value[:25]
|
|
|
|
def print_label(self, instance):
|
|
"""Handle print button press"""
|
|
sap_nr = self.sap_input.text.strip()
|
|
quantity = self.qty_input.text.strip()
|
|
cable_id = self.cable_id_input.text.strip()
|
|
# Resolve display name to full printer name
|
|
printer = self._get_full_printer_name(self.printer_spinner.text)
|
|
|
|
# Validate input
|
|
if not sap_nr and not quantity and not cable_id:
|
|
self.show_popup("Error", "Please enter at least one field")
|
|
return
|
|
|
|
# Create combined label text
|
|
label_text = f"{sap_nr}|{quantity}|{cable_id}"
|
|
|
|
# Show loading popup
|
|
popup = Popup(
|
|
title='Printing',
|
|
content=BoxLayout(
|
|
orientation='vertical',
|
|
padding=10,
|
|
spacing=10
|
|
),
|
|
size_hint=(0.8, 0.3)
|
|
)
|
|
|
|
popup.content.add_widget(Label(text='Processing label...\nPlease wait'))
|
|
popup.open()
|
|
|
|
# Print in background thread (using PDF by default)
|
|
def print_thread():
|
|
pdf_filename = None
|
|
success = False
|
|
try:
|
|
success = print_label_standalone(label_text, printer, preview=0, use_pdf=True)
|
|
|
|
# Get the PDF filename that was created
|
|
# Files are saved to pdf_backup/ with timestamp
|
|
pdf_files = glob.glob('pdf_backup/final_label_*.pdf')
|
|
if pdf_files:
|
|
# Get the most recently created PDF file
|
|
pdf_filename = max(pdf_files, key=os.path.getctime)
|
|
|
|
if success:
|
|
# Log the successful print action
|
|
self.log_print_action(sap_nr, quantity, cable_id, printer, pdf_filename or "unknown", True)
|
|
|
|
# Use Clock.schedule_once to update UI from main thread
|
|
Clock.schedule_once(lambda dt: popup.dismiss(), 0)
|
|
Clock.schedule_once(lambda dt: self.show_popup("Success", "Label printed successfully!", auto_dismiss=True), 0.1)
|
|
# Clear inputs after successful print (but keep printer selection)
|
|
Clock.schedule_once(lambda dt: self.clear_inputs(), 0.2)
|
|
else:
|
|
# Log the failed print action
|
|
self.log_print_action(sap_nr, quantity, cable_id, printer, pdf_filename or "unknown", False)
|
|
|
|
Clock.schedule_once(lambda dt: popup.dismiss(), 0)
|
|
Clock.schedule_once(lambda dt: self.show_popup("Error", "Failed to print label"), 0.1)
|
|
except Exception as e:
|
|
# Log the error
|
|
self.log_print_action(sap_nr, quantity, cable_id, printer, pdf_filename or "unknown", False)
|
|
|
|
Clock.schedule_once(lambda dt: popup.dismiss(), 0)
|
|
Clock.schedule_once(lambda dt: self.show_popup("Error", f"Print error: {str(e)}"), 0.1)
|
|
|
|
thread = threading.Thread(target=print_thread)
|
|
thread.daemon = True
|
|
thread.start()
|
|
|
|
def clear_inputs(self):
|
|
"""Clear only the input fields, preserving printer selection"""
|
|
self.sap_input.text = ''
|
|
self.qty_input.text = ''
|
|
self.cable_id_input.text = ''
|
|
# Printer selection is NOT cleared - it persists until user changes it
|
|
|
|
def show_popup(self, title, message, auto_dismiss=False):
|
|
"""Show a popup message
|
|
|
|
Args:
|
|
title (str): Popup title
|
|
message (str): Popup message
|
|
auto_dismiss (bool): If True, popup will auto-dismiss after 3 seconds
|
|
"""
|
|
popup = Popup(
|
|
title=title,
|
|
content=BoxLayout(
|
|
orientation='vertical',
|
|
padding=10,
|
|
spacing=10
|
|
),
|
|
size_hint=(0.8, 0.4)
|
|
)
|
|
|
|
popup.content.add_widget(Label(text=message))
|
|
|
|
close_button = Button(text='OK', size_hint_y=0.3)
|
|
close_button.bind(on_press=popup.dismiss)
|
|
popup.content.add_widget(close_button)
|
|
|
|
popup.open()
|
|
|
|
# Auto-dismiss after 3 seconds if requested
|
|
if auto_dismiss:
|
|
Clock.schedule_once(lambda dt: popup.dismiss(), 3)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
app = LabelPrinterApp()
|
|
app.run()
|