Add library browser, BOM generation, and UI improvements
- Add parts library browser with ODBC database search - Implement hierarchical BOM generation with PCB side detection - Add STEP export and PCB rendering functionality - Adjust page width to 80% up to 1536px max - Add library-only mode for standalone library browsing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
330
app.py
330
app.py
@@ -13,6 +13,7 @@ from flask_socketio import SocketIO, emit
|
||||
import time
|
||||
from PyPDF2 import PdfMerger
|
||||
from variant_manager import VariantManager
|
||||
import pyodbc
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = 'secret!'
|
||||
@@ -57,7 +58,10 @@ def index():
|
||||
cmd_parts.append(arg)
|
||||
invocation_cmd = ' '.join(cmd_parts)
|
||||
|
||||
return render_template('index.html', args=app_args, invocation_cmd=invocation_cmd)
|
||||
# Check if called with no arguments (library-only mode)
|
||||
library_only_mode = len(sys.argv) == 1 or not app_args
|
||||
|
||||
return render_template('index.html', args=app_args, invocation_cmd=invocation_cmd, library_only_mode=library_only_mode)
|
||||
|
||||
@socketio.on('connect')
|
||||
def handle_connect():
|
||||
@@ -265,6 +269,81 @@ def handle_generate_gerbers():
|
||||
except Exception as e:
|
||||
emit('gerber_error', {'error': str(e)})
|
||||
|
||||
@socketio.on('export_step')
|
||||
def handle_export_step():
|
||||
try:
|
||||
kicad_cli = app_args.get('Kicad Cli', '')
|
||||
board_file = app_args.get('Board File', '')
|
||||
project_dir = app_args.get('Project Dir', '')
|
||||
project_name = app_args.get('Project Name', 'project')
|
||||
|
||||
if not kicad_cli or not board_file:
|
||||
emit('step_error', {'error': 'Missing kicad-cli or board-file arguments'})
|
||||
return
|
||||
|
||||
# Create output filename
|
||||
step_filename = f'{project_name}.step'
|
||||
step_path = os.path.join(project_dir if project_dir else os.path.dirname(board_file), step_filename)
|
||||
|
||||
# Generate STEP file
|
||||
emit('step_status', {'status': 'Exporting PCB to STEP format...'})
|
||||
cmd = [kicad_cli, 'pcb', 'export', 'step', board_file, '-o', step_path]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
emit('step_error', {'error': f'STEP export failed: {result.stderr}'})
|
||||
return
|
||||
|
||||
if not os.path.exists(step_path):
|
||||
emit('step_error', {'error': 'STEP file was not created'})
|
||||
return
|
||||
|
||||
emit('step_complete', {'path': step_path, 'filename': step_filename})
|
||||
|
||||
except Exception as e:
|
||||
emit('step_error', {'error': str(e)})
|
||||
|
||||
@socketio.on('render_pcb')
|
||||
def handle_render_pcb():
|
||||
try:
|
||||
kicad_cli = app_args.get('Kicad Cli', '')
|
||||
board_file = app_args.get('Board File', '')
|
||||
project_dir = app_args.get('Project Dir', '')
|
||||
project_name = app_args.get('Project Name', 'project')
|
||||
|
||||
if not kicad_cli or not board_file:
|
||||
emit('render_error', {'error': 'Missing kicad-cli or board-file arguments'})
|
||||
return
|
||||
|
||||
# Create output filename
|
||||
render_filename = f'{project_name}_iso_view.png'
|
||||
render_path = os.path.join(project_dir if project_dir else os.path.dirname(board_file), render_filename)
|
||||
|
||||
# Render PCB with isometric view
|
||||
emit('render_status', {'status': 'Rendering PCB image...'})
|
||||
cmd = [
|
||||
kicad_cli, 'pcb', 'render', board_file,
|
||||
'--rotate', '25,0,45',
|
||||
'-o', render_path
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = f'PCB render failed:\nCommand: {" ".join(cmd)}\nStderr: {result.stderr}\nStdout: {result.stdout}'
|
||||
emit('render_error', {'error': error_msg})
|
||||
return
|
||||
|
||||
if not os.path.exists(render_path):
|
||||
emit('render_error', {'error': 'Rendered image was not created'})
|
||||
return
|
||||
|
||||
emit('render_complete', {'path': render_path, 'filename': render_filename})
|
||||
|
||||
except Exception as e:
|
||||
emit('render_error', {'error': str(e)})
|
||||
|
||||
@socketio.on('sync_libraries')
|
||||
def handle_sync_libraries():
|
||||
try:
|
||||
@@ -599,6 +678,12 @@ def handle_delete_variant(data):
|
||||
@socketio.on('activate_variant')
|
||||
def handle_activate_variant(data):
|
||||
try:
|
||||
import pygetwindow as gw
|
||||
import pyautogui
|
||||
import time
|
||||
import win32gui
|
||||
import win32con
|
||||
|
||||
manager = get_variant_manager()
|
||||
if not manager:
|
||||
emit('variant_error', {'error': 'No project loaded'})
|
||||
@@ -608,30 +693,78 @@ def handle_activate_variant(data):
|
||||
schematic_file = app_args.get('Schematic File', '')
|
||||
kicad_cli = app_args.get('Kicad Cli', 'kicad-cli')
|
||||
|
||||
# First, sync the current variant from schematic to capture any manual changes
|
||||
current_variant = manager.get_active_variant()
|
||||
# Step 1: Save and close schematic window
|
||||
emit('variant_status', {'status': 'Looking for KiCad schematic window...'})
|
||||
|
||||
all_windows = gw.getAllTitles()
|
||||
windows = gw.getWindowsWithTitle('Schematic Editor')
|
||||
|
||||
window_found = False
|
||||
if not windows:
|
||||
schematic_windows = [w for w in all_windows if 'kicad' in w.lower() and 'schematic' in w.lower()]
|
||||
if schematic_windows:
|
||||
windows = gw.getWindowsWithTitle(schematic_windows[0])
|
||||
window_found = len(windows) > 0
|
||||
else:
|
||||
window_found = True
|
||||
|
||||
# If window is found, save and close it
|
||||
if window_found:
|
||||
window = windows[0]
|
||||
emit('variant_status', {'status': f'Saving and closing: {window.title}'})
|
||||
|
||||
hwnd = window._hWnd
|
||||
rect = win32gui.GetWindowRect(hwnd)
|
||||
x, y, x2, y2 = rect
|
||||
width = x2 - x
|
||||
|
||||
# Click to activate
|
||||
click_x = x + width // 2
|
||||
click_y = y + 10
|
||||
pyautogui.click(click_x, click_y)
|
||||
time.sleep(0.5)
|
||||
|
||||
# Save
|
||||
pyautogui.hotkey('ctrl', 's')
|
||||
time.sleep(1.0)
|
||||
|
||||
# Close
|
||||
win32gui.PostMessage(hwnd, win32con.WM_CLOSE, 0, 0)
|
||||
time.sleep(1.0)
|
||||
|
||||
# Try Alt+F4 if still open
|
||||
if win32gui.IsWindow(hwnd):
|
||||
try:
|
||||
rect = win32gui.GetWindowRect(hwnd)
|
||||
x, y, x2, y2 = rect
|
||||
click_x = x + (x2 - x) // 2
|
||||
click_y = y + 10
|
||||
pyautogui.click(click_x, click_y)
|
||||
time.sleep(0.3)
|
||||
except:
|
||||
pass
|
||||
pyautogui.hotkey('alt', 'F4')
|
||||
time.sleep(1.0)
|
||||
|
||||
# Step 2: Sync current variant from schematic
|
||||
current_variant = manager.get_active_variant()
|
||||
emit('variant_status', {'status': f'Syncing current variant "{current_variant}"...'})
|
||||
|
||||
print(f"Syncing current variant '{current_variant}' before switching...")
|
||||
# Import and call sync function directly instead of subprocess
|
||||
from sync_variant import sync_variant_from_schematic
|
||||
try:
|
||||
sync_success = sync_variant_from_schematic(schematic_file, current_variant)
|
||||
if sync_success:
|
||||
print(f"Successfully synced variant '{current_variant}'")
|
||||
# Reload the manager to get the updated data
|
||||
manager = get_variant_manager()
|
||||
else:
|
||||
print(f"Warning: Sync of variant '{current_variant}' failed")
|
||||
except Exception as e:
|
||||
print(f"Error during sync: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Now activate the new variant
|
||||
# Step 3: Activate new variant and apply to schematic
|
||||
emit('variant_status', {'status': f'Activating variant "{name}"...'})
|
||||
success = manager.set_active_variant(name)
|
||||
|
||||
if success:
|
||||
# Apply new variant to schematic
|
||||
apply_script_path = os.path.join(os.path.dirname(__file__), 'apply_variant.py')
|
||||
|
||||
result = subprocess.run(
|
||||
@@ -640,15 +773,39 @@ def handle_activate_variant(data):
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
emit('variant_updated', {'message': f'Synced "{current_variant}", then activated and applied "{name}"'})
|
||||
else:
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr if result.stderr else result.stdout
|
||||
emit('variant_error', {'error': f'Failed to apply variant: {error_msg}'})
|
||||
return
|
||||
else:
|
||||
emit('variant_error', {'error': f'Variant "{name}" not found'})
|
||||
return
|
||||
|
||||
# Step 4: Reopen schematic editor
|
||||
emit('variant_status', {'status': 'Waiting 2 seconds before reopening...'})
|
||||
time.sleep(2.0)
|
||||
|
||||
emit('variant_status', {'status': 'Reopening schematic editor...'})
|
||||
|
||||
kicad_bin_dir = r"C:\Program Files\KiCad\9.0\bin"
|
||||
if not os.path.exists(kicad_bin_dir):
|
||||
kicad_bin_dir = r"C:\Program Files\KiCad\8.0\bin"
|
||||
|
||||
eeschema_exe = os.path.join(kicad_bin_dir, "eeschema.exe")
|
||||
|
||||
if os.path.exists(eeschema_exe):
|
||||
subprocess.Popen([eeschema_exe, schematic_file], shell=False)
|
||||
time.sleep(2.0)
|
||||
else:
|
||||
emit('variant_status', {'status': f'Warning: eeschema.exe not found at {eeschema_exe}'})
|
||||
|
||||
emit('variant_updated', {'message': f'Activated variant "{name}" and reopened schematic'})
|
||||
|
||||
except ImportError as e:
|
||||
emit('variant_error', {'error': f'Missing library: {str(e)}. Install: pip install pygetwindow pyautogui pywin32'})
|
||||
except Exception as e:
|
||||
emit('variant_error', {'error': str(e)})
|
||||
import traceback
|
||||
emit('variant_error', {'error': f'{str(e)}\n{traceback.format_exc()}'})
|
||||
|
||||
@socketio.on('get_variant_parts')
|
||||
def handle_get_variant_parts(data):
|
||||
@@ -860,6 +1017,149 @@ def handle_test_window_interaction():
|
||||
import traceback
|
||||
emit('window_test_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Library Management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_db_connection():
|
||||
"""Get ODBC connection to the parts database"""
|
||||
try:
|
||||
# Use DSN connection as configured in KiCad database library
|
||||
conn_str = "DSN=UM_KiCad_Parts;"
|
||||
conn = pyodbc.connect(conn_str)
|
||||
return conn
|
||||
except Exception as e:
|
||||
print(f"Database connection error: {e}")
|
||||
return None
|
||||
|
||||
@socketio.on('search_parts')
|
||||
def handle_search_parts(data):
|
||||
try:
|
||||
search_query = data.get('query', '').strip()
|
||||
|
||||
conn = get_db_connection()
|
||||
if not conn:
|
||||
emit('library_error', {'error': 'Could not connect to database'})
|
||||
return
|
||||
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Build search query - search across ipn, mpn, manufacturer, and description
|
||||
# Column names in SQLite are lowercase
|
||||
if search_query:
|
||||
sql = """
|
||||
SELECT ipn, mpn, manufacturer, description
|
||||
FROM parts
|
||||
WHERE ipn LIKE ?
|
||||
OR mpn LIKE ?
|
||||
OR manufacturer LIKE ?
|
||||
OR description LIKE ?
|
||||
ORDER BY ipn
|
||||
LIMIT 100
|
||||
"""
|
||||
search_param = f'%{search_query}%'
|
||||
cursor.execute(sql, (search_param, search_param, search_param, search_param))
|
||||
else:
|
||||
# No search query - return first 100 parts
|
||||
sql = """
|
||||
SELECT ipn, mpn, manufacturer, description
|
||||
FROM parts
|
||||
ORDER BY ipn
|
||||
LIMIT 100
|
||||
"""
|
||||
cursor.execute(sql)
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
parts = []
|
||||
for row in rows:
|
||||
parts.append({
|
||||
'ipn': row.ipn if row.ipn else '',
|
||||
'mpn': row.mpn if row.mpn else '',
|
||||
'manufacturer': row.manufacturer if row.manufacturer else '',
|
||||
'description': row.description if row.description else ''
|
||||
})
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
emit('library_search_results', {'parts': parts, 'count': len(parts)})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BOM Generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@socketio.on('generate_bom')
|
||||
def handle_generate_bom():
|
||||
try:
|
||||
manager = get_variant_manager()
|
||||
if not manager:
|
||||
emit('bom_error', {'error': 'No project loaded'})
|
||||
return
|
||||
|
||||
schematic_file = app_args.get('Schematic File', '')
|
||||
board_file = app_args.get('Board File', '')
|
||||
project_name = app_args.get('Project Name', 'project')
|
||||
project_dir = app_args.get('Project Dir', '')
|
||||
|
||||
if not schematic_file:
|
||||
emit('bom_error', {'error': 'No schematic file specified'})
|
||||
return
|
||||
|
||||
# Get active variant and DNP parts
|
||||
active_variant = manager.get_active_variant()
|
||||
dnp_uuids = manager.get_dnp_parts(active_variant)
|
||||
|
||||
emit('bom_status', {'status': 'Generating BOMs...'})
|
||||
|
||||
# Call BOM generator script
|
||||
bom_script_path = os.path.join(os.path.dirname(__file__), 'bom_generator.py')
|
||||
|
||||
# Build command with optional PCB file
|
||||
cmd = [sys.executable, bom_script_path, schematic_file, project_name, active_variant, json.dumps(dnp_uuids)]
|
||||
if board_file:
|
||||
cmd.append(board_file)
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Create ZIP of all BOMs
|
||||
output_dir = project_dir if project_dir else os.path.dirname(schematic_file)
|
||||
base_name = f"{project_name}_{active_variant}"
|
||||
|
||||
zip_filename = f"{base_name}_BOMs.zip"
|
||||
zip_path = os.path.join(output_dir, zip_filename)
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'w') as zipf:
|
||||
# Add all BOM files
|
||||
bom_files = [
|
||||
f"{base_name}_BOM.xlsx",
|
||||
f"{base_name}_Not_Populated.csv",
|
||||
f"{base_name}_BOM_Top.xlsx",
|
||||
f"{base_name}_BOM_Bottom.xlsx"
|
||||
]
|
||||
|
||||
for bom_file in bom_files:
|
||||
full_path = os.path.join(output_dir, bom_file)
|
||||
if os.path.exists(full_path):
|
||||
zipf.write(full_path, bom_file)
|
||||
|
||||
emit('bom_complete', {'path': zip_path, 'filename': zip_filename})
|
||||
else:
|
||||
emit('bom_error', {'error': f'BOM generation failed: {result.stderr}'})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
emit('bom_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
|
||||
|
||||
def shutdown_server():
|
||||
print("Server stopped")
|
||||
os._exit(0)
|
||||
|
||||
445
bom_generator.py
Normal file
445
bom_generator.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
BOM Generator for KiCad projects
|
||||
Generates fitted, unfitted, top-side, and bottom-side BOMs
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill
|
||||
import csv
|
||||
|
||||
|
||||
def find_hierarchical_sheets(schematic_file):
|
||||
"""
|
||||
Find all hierarchical sheet files referenced in the schematic
|
||||
Returns list of sheet file paths
|
||||
"""
|
||||
sheet_files = []
|
||||
base_dir = os.path.dirname(schematic_file)
|
||||
|
||||
with open(schematic_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
in_sheet = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
if stripped.startswith('(sheet'):
|
||||
in_sheet = True
|
||||
elif in_sheet and '(property "Sheetfile"' in line:
|
||||
parts = line.split('"')
|
||||
if len(parts) >= 4:
|
||||
sheet_filename = parts[3]
|
||||
sheet_path = os.path.join(base_dir, sheet_filename)
|
||||
if os.path.exists(sheet_path):
|
||||
sheet_files.append(sheet_path)
|
||||
# Recursively find sheets in the child schematic
|
||||
child_sheets = find_hierarchical_sheets(sheet_path)
|
||||
sheet_files.extend(child_sheets)
|
||||
in_sheet = False
|
||||
|
||||
return sheet_files
|
||||
|
||||
|
||||
def parse_pcb_for_component_sides(pcb_file):
|
||||
"""
|
||||
Parse PCB file to determine which side each component is on
|
||||
Returns dict mapping reference -> 'Top' or 'Bottom'
|
||||
"""
|
||||
component_sides = {}
|
||||
|
||||
if not os.path.exists(pcb_file):
|
||||
return component_sides
|
||||
|
||||
with open(pcb_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
in_footprint = False
|
||||
current_ref = None
|
||||
current_layer = None
|
||||
footprint_indent = None
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
indent_level = len(line) - len(line.lstrip())
|
||||
|
||||
# Detect start of footprint
|
||||
if stripped.startswith('(footprint'):
|
||||
in_footprint = True
|
||||
current_ref = None
|
||||
current_layer = None
|
||||
footprint_indent = indent_level
|
||||
|
||||
# Detect end of footprint
|
||||
elif in_footprint and stripped == ')' and indent_level == footprint_indent:
|
||||
# Store the side mapping
|
||||
if current_ref and current_layer:
|
||||
side = 'Top' if current_layer == 'F.Cu' else 'Bottom' if current_layer == 'B.Cu' else '-'
|
||||
component_sides[current_ref] = side
|
||||
in_footprint = False
|
||||
current_ref = None
|
||||
current_layer = None
|
||||
|
||||
# Extract reference
|
||||
elif in_footprint and '(property "Reference"' in line:
|
||||
parts = line.split('"')
|
||||
if len(parts) >= 4:
|
||||
current_ref = parts[3]
|
||||
|
||||
# Extract layer
|
||||
elif in_footprint and '(layer' in stripped:
|
||||
parts = line.split('"')
|
||||
if len(parts) >= 2:
|
||||
current_layer = parts[1]
|
||||
|
||||
return component_sides
|
||||
|
||||
|
||||
def parse_single_schematic(schematic_file, dnp_uuids=None):
|
||||
"""
|
||||
Parse a single schematic file for components
|
||||
"""
|
||||
if dnp_uuids is None:
|
||||
dnp_uuids = []
|
||||
|
||||
components = []
|
||||
|
||||
with open(schematic_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
in_symbol = False
|
||||
current_component = {}
|
||||
symbol_start_indent = None
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
indent_level = len(line) - len(line.lstrip())
|
||||
|
||||
# Detect start of symbol
|
||||
if stripped.startswith('(symbol'):
|
||||
in_symbol = True
|
||||
current_component = {
|
||||
'reference': '',
|
||||
'value': '',
|
||||
'footprint': '',
|
||||
'ipn': '',
|
||||
'mpn': '',
|
||||
'manufacturer': '',
|
||||
'description': '',
|
||||
'datasheet': '',
|
||||
'side': '-',
|
||||
'is_dnp': False,
|
||||
'uuid': ''
|
||||
}
|
||||
symbol_start_indent = indent_level
|
||||
|
||||
# Detect end of symbol
|
||||
elif in_symbol and stripped == ')' and indent_level == symbol_start_indent:
|
||||
# Only add if we have a reference (filter out power symbols, etc.)
|
||||
if current_component['reference'] and not current_component['reference'].startswith('#'):
|
||||
# Check if lib_id contains "power:"
|
||||
if 'lib_id' in current_component and 'power:' not in current_component.get('lib_id', ''):
|
||||
components.append(current_component.copy())
|
||||
in_symbol = False
|
||||
current_component = {}
|
||||
|
||||
# Extract lib_id
|
||||
elif in_symbol and '(lib_id' in stripped:
|
||||
parts = line.split('"')
|
||||
if len(parts) >= 2:
|
||||
current_component['lib_id'] = parts[1]
|
||||
|
||||
# Extract UUID (comes after DNP line)
|
||||
elif in_symbol and '(dnp' in stripped:
|
||||
# Look forward for UUID
|
||||
for j in range(i + 1, min(len(lines), i + 5)):
|
||||
if '(uuid' in lines[j]:
|
||||
if '\t(uuid' in lines[j] or ' (uuid' in lines[j]:
|
||||
uuid_parts = lines[j].split('"')
|
||||
if len(uuid_parts) >= 2:
|
||||
current_component['uuid'] = uuid_parts[1]
|
||||
# Check if DNP
|
||||
if current_component['uuid'] in dnp_uuids or '(dnp yes)' in stripped or stripped == '(dnp)':
|
||||
current_component['is_dnp'] = True
|
||||
break
|
||||
|
||||
# Extract properties
|
||||
elif in_symbol and '(property' in line:
|
||||
parts = line.split('"')
|
||||
if len(parts) >= 4:
|
||||
prop_name = parts[1]
|
||||
prop_value = parts[3]
|
||||
|
||||
if prop_name == 'Reference':
|
||||
current_component['reference'] = prop_value
|
||||
elif prop_name == 'Value':
|
||||
current_component['value'] = prop_value
|
||||
elif prop_name == 'Footprint':
|
||||
current_component['footprint'] = prop_value
|
||||
elif prop_name == 'IPN':
|
||||
current_component['ipn'] = prop_value
|
||||
elif prop_name == 'MPN':
|
||||
current_component['mpn'] = prop_value
|
||||
elif prop_name == 'Manufacturer':
|
||||
current_component['manufacturer'] = prop_value
|
||||
elif prop_name == 'Description':
|
||||
current_component['description'] = prop_value
|
||||
elif prop_name == 'Datasheet':
|
||||
current_component['datasheet'] = prop_value
|
||||
|
||||
return components
|
||||
|
||||
|
||||
def parse_schematic_for_bom(schematic_file, dnp_uuids=None, pcb_file=None):
|
||||
"""
|
||||
Parse schematic hierarchy and PCB file to extract component information for BOM generation
|
||||
Traverses all hierarchical sheets and gets side information from PCB
|
||||
|
||||
Returns list of components with properties:
|
||||
- reference: Component reference designator
|
||||
- value: Component value
|
||||
- footprint: Footprint name
|
||||
- ipn: Internal Part Number
|
||||
- mpn: Manufacturer Part Number
|
||||
- manufacturer: Manufacturer name
|
||||
- description: Component description
|
||||
- side: Top/Bottom/- (from PCB layer)
|
||||
- is_dnp: Whether component is DNP (Do Not Place)
|
||||
- uuid: Component UUID
|
||||
"""
|
||||
if dnp_uuids is None:
|
||||
dnp_uuids = []
|
||||
|
||||
# Get component side information from PCB
|
||||
component_sides = {}
|
||||
if pcb_file and os.path.exists(pcb_file):
|
||||
print(f"Parsing PCB for component sides: {pcb_file}")
|
||||
component_sides = parse_pcb_for_component_sides(pcb_file)
|
||||
|
||||
# Parse root schematic
|
||||
print(f"Parsing root schematic: {schematic_file}")
|
||||
components = parse_single_schematic(schematic_file, dnp_uuids)
|
||||
|
||||
# Find and parse hierarchical sheets
|
||||
sheet_files = find_hierarchical_sheets(schematic_file)
|
||||
for sheet_file in sheet_files:
|
||||
print(f"Parsing hierarchical sheet: {sheet_file}")
|
||||
sheet_components = parse_single_schematic(sheet_file, dnp_uuids)
|
||||
components.extend(sheet_components)
|
||||
|
||||
# Apply side information from PCB
|
||||
for comp in components:
|
||||
if comp['reference'] in component_sides:
|
||||
comp['side'] = component_sides[comp['reference']]
|
||||
|
||||
print(f"Total components found: {len(components)}")
|
||||
return components
|
||||
|
||||
|
||||
def group_components_for_bom(components):
|
||||
"""
|
||||
Group components by IPN and side for BOM generation
|
||||
Returns list of grouped components with consolidated references
|
||||
"""
|
||||
# Group by (ipn, side, value, footprint, manufacturer, mpn)
|
||||
groups = defaultdict(list)
|
||||
|
||||
for comp in components:
|
||||
# Create grouping key
|
||||
key = (
|
||||
comp.get('ipn', ''),
|
||||
comp.get('side', '-'),
|
||||
comp.get('value', ''),
|
||||
comp.get('footprint', ''),
|
||||
comp.get('manufacturer', ''),
|
||||
comp.get('mpn', ''),
|
||||
comp.get('description', '')
|
||||
)
|
||||
groups[key].append(comp)
|
||||
|
||||
# Create BOM lines
|
||||
bom_lines = []
|
||||
line_num = 1
|
||||
|
||||
for key, comps in sorted(groups.items()):
|
||||
ipn, side, value, footprint, manufacturer, mpn, description = key
|
||||
|
||||
# Sort references naturally (R1, R2, R10, etc.)
|
||||
refs = sorted([c['reference'] for c in comps], key=lambda x: (x.rstrip('0123456789'), int(''.join(filter(str.isdigit, x)) or 0)))
|
||||
refdes_str = ', '.join(refs)
|
||||
|
||||
# Determine SMD/TH from footprint
|
||||
smd_th = 'SMD' if 'SMD' in footprint.upper() or any(x in footprint for x in ['0402', '0603', '0805', '1206']) else 'TH'
|
||||
|
||||
bom_line = {
|
||||
'line_num': line_num,
|
||||
'refdes': refdes_str,
|
||||
'ipn': ipn if ipn else 'N/A',
|
||||
'qty': len(comps),
|
||||
'description': description,
|
||||
'footprint': footprint,
|
||||
'manufacturer': manufacturer,
|
||||
'mpn': mpn,
|
||||
'notes': '',
|
||||
'smd_th': smd_th,
|
||||
'side': side,
|
||||
'populated': True # Since we're only including fitted parts
|
||||
}
|
||||
|
||||
bom_lines.append(bom_line)
|
||||
line_num += 1
|
||||
|
||||
return bom_lines
|
||||
|
||||
|
||||
def generate_main_bom_xlsx(bom_lines, output_file, project_name, variant_name):
|
||||
"""
|
||||
Generate main BOM in XLSX format matching the reference format
|
||||
"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "BOM"
|
||||
|
||||
# Set column widths
|
||||
ws.column_dimensions['A'].width = 5
|
||||
ws.column_dimensions['B'].width = 60
|
||||
ws.column_dimensions['C'].width = 12
|
||||
ws.column_dimensions['D'].width = 6
|
||||
ws.column_dimensions['E'].width = 40
|
||||
ws.column_dimensions['F'].width = 25
|
||||
ws.column_dimensions['G'].width = 15
|
||||
ws.column_dimensions['H'].width = 20
|
||||
ws.column_dimensions['I'].width = 15
|
||||
ws.column_dimensions['J'].width = 8
|
||||
ws.column_dimensions['K'].width = 8
|
||||
ws.column_dimensions['L'].width = 10
|
||||
|
||||
# Add header row (row 2)
|
||||
headers = ['#', 'RefDes', 'GLE P/N', 'Qty', 'Description', 'Footprint',
|
||||
'Mfg 1', 'Mfg 1 P/N', 'Notes', 'SMD/TH', 'Side', 'Populated',
|
||||
'Mfg 2', 'Mfg 2 P/N', 'Mfg 3', 'Mfg 3 P/N', 'Mfg 4', 'Mfg 4 P/N',
|
||||
'Mfg 5', 'Mfg 5 P/N']
|
||||
|
||||
for col_idx, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=2, column=col_idx)
|
||||
cell.value = header
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = PatternFill(start_color='D3D3D3', end_color='D3D3D3', fill_type='solid')
|
||||
cell.alignment = Alignment(horizontal='left', vertical='center')
|
||||
|
||||
# Add BOM lines
|
||||
for idx, bom_line in enumerate(bom_lines, 3):
|
||||
ws.cell(row=idx, column=1, value=bom_line['line_num'])
|
||||
ws.cell(row=idx, column=2, value=bom_line['refdes'])
|
||||
ws.cell(row=idx, column=3, value=bom_line['ipn'])
|
||||
ws.cell(row=idx, column=4, value=bom_line['qty'])
|
||||
ws.cell(row=idx, column=5, value=bom_line['description'])
|
||||
ws.cell(row=idx, column=6, value=bom_line['footprint'])
|
||||
ws.cell(row=idx, column=7, value=bom_line['manufacturer'])
|
||||
ws.cell(row=idx, column=8, value=bom_line['mpn'])
|
||||
ws.cell(row=idx, column=9, value=bom_line['notes'])
|
||||
ws.cell(row=idx, column=10, value=bom_line['smd_th'])
|
||||
ws.cell(row=idx, column=11, value=bom_line['side'])
|
||||
ws.cell(row=idx, column=12, value=bom_line['populated'])
|
||||
|
||||
wb.save(output_file)
|
||||
print(f"Generated main BOM: {output_file}")
|
||||
|
||||
|
||||
def generate_unfitted_bom_csv(components, output_file, project_name, variant_name):
|
||||
"""
|
||||
Generate unfitted (DNP) parts BOM in CSV format
|
||||
"""
|
||||
# Filter only DNP parts
|
||||
dnp_parts = [c for c in components if c['is_dnp']]
|
||||
|
||||
# Sort by reference
|
||||
dnp_parts.sort(key=lambda x: (x['reference'].rstrip('0123456789'),
|
||||
int(''.join(filter(str.isdigit, x['reference'])) or 0)))
|
||||
|
||||
with open(output_file, 'w', newline='', encoding='utf-8') as csvfile:
|
||||
writer = csv.writer(csvfile, quoting=csv.QUOTE_ALL)
|
||||
|
||||
# Header
|
||||
writer.writerow(['Components Not Populated Report'])
|
||||
writer.writerow(['Variant', variant_name, project_name])
|
||||
writer.writerow(['Assembly', '-', ''])
|
||||
writer.writerow(['----------'])
|
||||
|
||||
# DNP parts
|
||||
for part in dnp_parts:
|
||||
writer.writerow([part['reference'], 'Not populated'])
|
||||
|
||||
writer.writerow(['----------'])
|
||||
|
||||
print(f"Generated unfitted BOM: {output_file}")
|
||||
|
||||
|
||||
def generate_top_bom_xlsx(bom_lines, output_file, project_name, variant_name):
|
||||
"""
|
||||
Generate top-side only BOM in XLSX format
|
||||
"""
|
||||
# Filter only top-side parts
|
||||
top_lines = [line for line in bom_lines if line['side'] == 'Top']
|
||||
generate_main_bom_xlsx(top_lines, output_file, project_name, f"{variant_name} (Top)")
|
||||
|
||||
|
||||
def generate_bottom_bom_xlsx(bom_lines, output_file, project_name, variant_name):
|
||||
"""
|
||||
Generate bottom-side only BOM in XLSX format
|
||||
"""
|
||||
# Filter only bottom-side parts
|
||||
bottom_lines = [line for line in bom_lines if line['side'] == 'Bottom']
|
||||
generate_main_bom_xlsx(bottom_lines, output_file, project_name, f"{variant_name} (Bottom)")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
if len(sys.argv) < 4:
|
||||
print("Usage: python bom_generator.py <schematic_file> <project_name> <variant_name> [dnp_uuids_json] [pcb_file]")
|
||||
sys.exit(1)
|
||||
|
||||
schematic_file = sys.argv[1]
|
||||
project_name = sys.argv[2]
|
||||
variant_name = sys.argv[3]
|
||||
|
||||
dnp_uuids = []
|
||||
if len(sys.argv) > 4:
|
||||
import json
|
||||
dnp_uuids = json.loads(sys.argv[4])
|
||||
|
||||
pcb_file = None
|
||||
if len(sys.argv) > 5:
|
||||
pcb_file = sys.argv[5]
|
||||
|
||||
# Parse schematic with hierarchical sheets and PCB side info
|
||||
components = parse_schematic_for_bom(schematic_file, dnp_uuids, pcb_file)
|
||||
|
||||
# Filter fitted parts for main BOM
|
||||
fitted_components = [c for c in components if not c['is_dnp']]
|
||||
|
||||
# Group components
|
||||
bom_lines = group_components_for_bom(fitted_components)
|
||||
|
||||
# Generate output files
|
||||
output_dir = os.path.dirname(schematic_file)
|
||||
base_name = f"{project_name}_{variant_name}"
|
||||
|
||||
# Main BOM
|
||||
generate_main_bom_xlsx(bom_lines, os.path.join(output_dir, f"{base_name}_BOM.xlsx"),
|
||||
project_name, variant_name)
|
||||
|
||||
# Unfitted BOM
|
||||
generate_unfitted_bom_csv(components, os.path.join(output_dir, f"{base_name}_Not_Populated.csv"),
|
||||
project_name, variant_name)
|
||||
|
||||
# Top BOM
|
||||
generate_top_bom_xlsx(bom_lines, os.path.join(output_dir, f"{base_name}_BOM_Top.xlsx"),
|
||||
project_name, variant_name)
|
||||
|
||||
# Bottom BOM
|
||||
generate_bottom_bom_xlsx(bom_lines, os.path.join(output_dir, f"{base_name}_BOM_Bottom.xlsx"),
|
||||
project_name, variant_name)
|
||||
|
||||
print(f"BOM generation complete for variant: {variant_name}")
|
||||
@@ -8,7 +8,8 @@
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
width: 80%;
|
||||
max-width: 1536px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
@@ -270,12 +271,21 @@
|
||||
|
||||
<h1>KiCad Manager</h1>
|
||||
|
||||
{% if library_only_mode %}
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('main')">Main</button>
|
||||
<button class="tab" onclick="switchTab('variants')">Variant Manager</button>
|
||||
<button class="tab active" onclick="switchTab('library')">Library</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('debug')">Debug</button>
|
||||
<button class="tab" onclick="switchTab('publish')">Publish</button>
|
||||
<button class="tab" onclick="switchTab('library')">Library</button>
|
||||
<button class="tab" onclick="switchTab('variants')">Variants</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="mainTab" class="tab-content active">
|
||||
{% if not library_only_mode %}
|
||||
<div id="debugTab" class="tab-content active">
|
||||
|
||||
<div class="command-container">
|
||||
<h2>Invocation Command</h2>
|
||||
@@ -298,10 +308,16 @@
|
||||
<h2>Actions</h2>
|
||||
<button id="generatePdfBtn" class="btn">Generate All PDFs (Schematic + Board Layers)</button>
|
||||
<button id="generateGerbersBtn" class="btn" style="margin-left: 10px;">Generate Gerbers & Drill Files</button>
|
||||
<button id="exportStepBtn" class="btn" style="margin-left: 10px;">Export PCB to STEP</button>
|
||||
<button id="renderPcbBtn" class="btn" style="margin-left: 10px;">Render PCB Image</button>
|
||||
<button id="generateBomBtn" class="btn" style="margin-left: 10px;">Generate BOM (All Variants)</button>
|
||||
<button id="syncLibrariesBtn" class="btn" style="margin-left: 10px;">Sync Symbol Libraries</button>
|
||||
<button id="syncDbBtn" class="btn" style="margin-left: 10px;">Sync Parts to Database (R & C)</button>
|
||||
<div id="message" class="message"></div>
|
||||
<div id="gerberMessage" class="message"></div>
|
||||
<div id="stepMessage" class="message"></div>
|
||||
<div id="renderMessage" class="message"></div>
|
||||
<div id="bomMessage" class="message"></div>
|
||||
<div id="syncMessage" class="message"></div>
|
||||
<div id="dbSyncMessage" class="message"></div>
|
||||
</div>
|
||||
@@ -330,8 +346,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- End mainTab -->
|
||||
</div><!-- End debugTab -->
|
||||
|
||||
<!-- Publish Tab -->
|
||||
<div id="publishTab" class="tab-content">
|
||||
<div class="container">
|
||||
<h2>Build & Publish</h2>
|
||||
<p>Generate all manufacturing outputs in a single operation with progress feedback.</p>
|
||||
|
||||
<div class="actions">
|
||||
<h3>Coming Soon</h3>
|
||||
<p>This tab will allow you to:</p>
|
||||
<ul style="margin-left: 20px; line-height: 1.8;">
|
||||
<li>Generate PDFs (Schematic + Board)</li>
|
||||
<li>Export Gerbers & Drill Files</li>
|
||||
<li>Export STEP model</li>
|
||||
<li>Generate BOM</li>
|
||||
<li>Run design checks</li>
|
||||
</ul>
|
||||
<p style="margin-top: 20px;">All with a single button press and real-time progress tracking.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- End publishTab -->
|
||||
{% endif %}
|
||||
|
||||
<!-- Library Tab -->
|
||||
<div id="libraryTab" class="tab-content {% if library_only_mode %}active{% endif %}">
|
||||
<div class="container">
|
||||
<h2>Parts Library Browser</h2>
|
||||
|
||||
<div style="margin: 20px 0;">
|
||||
<input type="text" id="librarySearchInput" placeholder="Search by IPN, MPN, Manufacturer, or Description..."
|
||||
style="width: 70%; padding: 10px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px;">
|
||||
<button id="librarySearchBtn" class="btn" style="margin-left: 10px;">Search</button>
|
||||
<button id="libraryClearBtn" class="btn secondary" style="margin-left: 5px;">Clear</button>
|
||||
</div>
|
||||
|
||||
<div id="libraryMessage" class="message"></div>
|
||||
|
||||
<div id="libraryResults" style="margin-top: 20px;">
|
||||
<p style="color: #6c757d;">Enter a search query or click Search to browse all parts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- End libraryTab -->
|
||||
|
||||
{% if not library_only_mode %}
|
||||
<!-- Variant Manager Tab -->
|
||||
<div id="variantsTab" class="tab-content">
|
||||
<div class="container">
|
||||
@@ -381,8 +440,10 @@
|
||||
<button class="btn" onclick="closeEditPartsModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
const libraryOnlyMode = {{ 'true' if library_only_mode else 'false' }};
|
||||
const socket = io();
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
@@ -414,6 +475,8 @@
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
// Only initialize project-specific features if not in library-only mode
|
||||
if (!libraryOnlyMode) {
|
||||
// PDF Generation
|
||||
const generatePdfBtn = document.getElementById('generatePdfBtn');
|
||||
const messageEl = document.getElementById('message');
|
||||
@@ -486,6 +549,114 @@
|
||||
generateGerbersBtn.disabled = false;
|
||||
});
|
||||
|
||||
// STEP Export
|
||||
const exportStepBtn = document.getElementById('exportStepBtn');
|
||||
const stepMessageEl = document.getElementById('stepMessage');
|
||||
|
||||
function showStepMessage(text, type) {
|
||||
stepMessageEl.textContent = text;
|
||||
stepMessageEl.className = 'message ' + type;
|
||||
stepMessageEl.style.display = 'block';
|
||||
}
|
||||
|
||||
exportStepBtn.addEventListener('click', () => {
|
||||
exportStepBtn.disabled = true;
|
||||
showStepMessage('Exporting PCB to STEP...', 'info');
|
||||
socket.emit('export_step');
|
||||
});
|
||||
|
||||
socket.on('step_status', (data) => {
|
||||
showStepMessage(data.status, 'info');
|
||||
});
|
||||
|
||||
socket.on('step_complete', (data) => {
|
||||
showStepMessage('STEP file generated successfully! Downloading...', 'success');
|
||||
exportStepBtn.disabled = false;
|
||||
|
||||
// Trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = '/download/' + data.filename;
|
||||
link.download = data.filename;
|
||||
link.click();
|
||||
});
|
||||
|
||||
socket.on('step_error', (data) => {
|
||||
showStepMessage('Error: ' + data.error, 'error');
|
||||
exportStepBtn.disabled = false;
|
||||
});
|
||||
|
||||
// PCB Render
|
||||
const renderPcbBtn = document.getElementById('renderPcbBtn');
|
||||
const renderMessageEl = document.getElementById('renderMessage');
|
||||
|
||||
function showRenderMessage(text, type) {
|
||||
renderMessageEl.textContent = text;
|
||||
renderMessageEl.className = 'message ' + type;
|
||||
renderMessageEl.style.display = 'block';
|
||||
}
|
||||
|
||||
renderPcbBtn.addEventListener('click', () => {
|
||||
renderPcbBtn.disabled = true;
|
||||
showRenderMessage('Rendering PCB image...', 'info');
|
||||
socket.emit('render_pcb');
|
||||
});
|
||||
|
||||
socket.on('render_status', (data) => {
|
||||
showRenderMessage(data.status, 'info');
|
||||
});
|
||||
|
||||
socket.on('render_complete', (data) => {
|
||||
showRenderMessage('PCB image rendered successfully! Downloading...', 'success');
|
||||
renderPcbBtn.disabled = false;
|
||||
|
||||
// Trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = '/download/' + data.filename;
|
||||
link.download = data.filename;
|
||||
link.click();
|
||||
});
|
||||
|
||||
socket.on('render_error', (data) => {
|
||||
showRenderMessage('Error: ' + data.error, 'error');
|
||||
renderPcbBtn.disabled = false;
|
||||
});
|
||||
|
||||
// BOM Generation
|
||||
const generateBomBtn = document.getElementById('generateBomBtn');
|
||||
const bomMessageEl = document.getElementById('bomMessage');
|
||||
|
||||
function showBomMessage(text, type) {
|
||||
bomMessageEl.textContent = text;
|
||||
bomMessageEl.className = 'message ' + type;
|
||||
bomMessageEl.style.display = 'block';
|
||||
}
|
||||
|
||||
generateBomBtn.addEventListener('click', () => {
|
||||
generateBomBtn.disabled = true;
|
||||
showBomMessage('Generating BOMs...', 'info');
|
||||
socket.emit('generate_bom');
|
||||
});
|
||||
|
||||
socket.on('bom_status', (data) => {
|
||||
showBomMessage(data.status, 'info');
|
||||
});
|
||||
|
||||
socket.on('bom_complete', (data) => {
|
||||
showBomMessage('BOMs generated successfully! Downloading ZIP archive...', 'success');
|
||||
generateBomBtn.disabled = false;
|
||||
|
||||
// Trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = '/download/' + data.filename;
|
||||
link.download = data.filename;
|
||||
link.click();
|
||||
});
|
||||
|
||||
socket.on('bom_error', (data) => {
|
||||
showBomMessage('Error: ' + data.error, 'error');
|
||||
generateBomBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Library Synchronization
|
||||
const syncLibrariesBtn = document.getElementById('syncLibrariesBtn');
|
||||
const syncMessageEl = document.getElementById('syncMessage');
|
||||
@@ -663,16 +834,98 @@
|
||||
});
|
||||
|
||||
// Show selected tab
|
||||
if (tabName === 'main') {
|
||||
document.getElementById('mainTab').classList.add('active');
|
||||
if (tabName === 'debug') {
|
||||
document.getElementById('debugTab').classList.add('active');
|
||||
document.querySelectorAll('.tab')[0].classList.add('active');
|
||||
} else if (tabName === 'publish') {
|
||||
document.getElementById('publishTab').classList.add('active');
|
||||
document.querySelectorAll('.tab')[1].classList.add('active');
|
||||
} else if (tabName === 'library') {
|
||||
document.getElementById('libraryTab').classList.add('active');
|
||||
document.querySelectorAll('.tab')[2].classList.add('active');
|
||||
} else if (tabName === 'variants') {
|
||||
document.getElementById('variantsTab').classList.add('active');
|
||||
document.querySelectorAll('.tab')[1].classList.add('active');
|
||||
document.querySelectorAll('.tab')[3].classList.add('active');
|
||||
loadVariants();
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Library Browser
|
||||
// ========================================
|
||||
const librarySearchInput = document.getElementById('librarySearchInput');
|
||||
const librarySearchBtn = document.getElementById('librarySearchBtn');
|
||||
const libraryClearBtn = document.getElementById('libraryClearBtn');
|
||||
const libraryMessageEl = document.getElementById('libraryMessage');
|
||||
const libraryResultsEl = document.getElementById('libraryResults');
|
||||
|
||||
function showLibraryMessage(text, type) {
|
||||
libraryMessageEl.textContent = text;
|
||||
libraryMessageEl.className = 'message ' + type;
|
||||
libraryMessageEl.style.display = 'block';
|
||||
}
|
||||
|
||||
function searchParts() {
|
||||
const query = librarySearchInput.value.trim();
|
||||
showLibraryMessage('Searching...', 'info');
|
||||
socket.emit('search_parts', { query: query });
|
||||
}
|
||||
|
||||
librarySearchBtn.addEventListener('click', searchParts);
|
||||
|
||||
librarySearchInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
searchParts();
|
||||
}
|
||||
});
|
||||
|
||||
libraryClearBtn.addEventListener('click', () => {
|
||||
librarySearchInput.value = '';
|
||||
libraryResultsEl.innerHTML = '<p style="color: #6c757d;">Enter a search query or click Search to browse all parts</p>';
|
||||
libraryMessageEl.style.display = 'none';
|
||||
});
|
||||
|
||||
socket.on('library_search_results', (data) => {
|
||||
libraryMessageEl.style.display = 'none';
|
||||
|
||||
if (data.parts.length === 0) {
|
||||
libraryResultsEl.innerHTML = '<p style="color: #6c757d;">No parts found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<p style="margin-bottom: 10px;"><strong>Found ${data.count} part(s)</strong></p>
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
|
||||
<thead>
|
||||
<tr style="background: #f8f9fa; border-bottom: 2px solid #dee2e6;">
|
||||
<th style="padding: 10px; text-align: left; border: 1px solid #dee2e6;">IPN</th>
|
||||
<th style="padding: 10px; text-align: left; border: 1px solid #dee2e6;">MPN</th>
|
||||
<th style="padding: 10px; text-align: left; border: 1px solid #dee2e6;">Manufacturer</th>
|
||||
<th style="padding: 10px; text-align: left; border: 1px solid #dee2e6;">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
for (const part of data.parts) {
|
||||
html += `
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 8px; border: 1px solid #dee2e6;">${part.ipn}</td>
|
||||
<td style="padding: 8px; border: 1px solid #dee2e6;">${part.mpn}</td>
|
||||
<td style="padding: 8px; border: 1px solid #dee2e6;">${part.manufacturer}</td>
|
||||
<td style="padding: 8px; border: 1px solid #dee2e6;">${part.description}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
libraryResultsEl.innerHTML = html;
|
||||
});
|
||||
|
||||
socket.on('library_error', (data) => {
|
||||
showLibraryMessage('Error: ' + data.error, 'error');
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Variant Manager
|
||||
// ========================================
|
||||
@@ -821,6 +1074,10 @@
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('variant_status', (data) => {
|
||||
showVariantMessage(data.status, 'info');
|
||||
});
|
||||
|
||||
socket.on('variant_updated', (data) => {
|
||||
showVariantMessage(data.message, 'success');
|
||||
loadVariants();
|
||||
@@ -877,6 +1134,7 @@
|
||||
event.target.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} // End of if (!libraryOnlyMode)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user