Enhance library browser with symbol/footprint resolution and datasheet links

- Add library path resolution for symbols and footprints using KiCad library tables
- Parse sym-lib-table and fp-lib-table with multi-line format support
- Resolve environment variables (KICAD9_SYMBOL_DIR, UM_KICAD, etc.)
- Auto-select symbol and footprint when loading part from database
- Add Datasheet column to parts search results with clickable links
- Make IPN, Description, Class, Manufacturer, and MPN fields read-only
- Remove file extensions (.kicad_sym, .pretty) from library dropdown display
- Fix column access in search results using index-based retrieval

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
brentperteet
2026-02-27 12:44:10 -06:00
parent 0de59dabfc
commit 5869d2c693
2 changed files with 357 additions and 32 deletions

217
app.py
View File

@@ -105,8 +105,10 @@ def get_symbol_libraries():
libraries = [] libraries = []
for filename in os.listdir(symbols_dir): for filename in os.listdir(symbols_dir):
if filename.endswith('.kicad_sym'): if filename.endswith('.kicad_sym'):
# Remove .kicad_sym extension for display
display_name = filename[:-10] if filename.endswith('.kicad_sym') else filename
libraries.append({ libraries.append({
'name': filename, 'name': display_name,
'path': os.path.join(symbols_dir, filename) 'path': os.path.join(symbols_dir, filename)
}) })
@@ -126,8 +128,10 @@ def get_footprint_libraries():
for item in os.listdir(footprints_dir): for item in os.listdir(footprints_dir):
item_path = os.path.join(footprints_dir, item) item_path = os.path.join(footprints_dir, item)
if os.path.isdir(item_path) and item.endswith('.pretty'): if os.path.isdir(item_path) and item.endswith('.pretty'):
# Remove .pretty extension for display
display_name = item[:-7] if item.endswith('.pretty') else item
libraries.append({ libraries.append({
'name': item, 'name': display_name,
'path': item_path 'path': item_path
}) })
@@ -632,8 +636,8 @@ def handle_disconnect():
# Shutdown if no clients connected after waiting for potential reconnect # Shutdown if no clients connected after waiting for potential reconnect
if not connected_clients: if not connected_clients:
print("No clients connected. Waiting 10 seconds for reconnect before shutting down...") print("No clients connected. Waiting 2 seconds for reconnect before shutting down...")
shutdown_timer = threading.Timer(10.0, shutdown_server) shutdown_timer = threading.Timer(2.0, shutdown_server)
shutdown_timer.start() shutdown_timer.start()
@socketio.on('heartbeat') @socketio.on('heartbeat')
@@ -1602,25 +1606,27 @@ def handle_search_parts(data):
cursor = conn.cursor() cursor = conn.cursor()
# Build search query - search across ipn, mpn, manufacturer, and description # Build search query - search across ipn, mpn, manufacturer, description, symbol, and footprint
# Column names in SQLite are lowercase # Column names in SQLite are lowercase
if search_query: if search_query:
sql = """ sql = """
SELECT ipn, mpn, manufacturer, description SELECT ipn, mpn, manufacturer, description, datasheet
FROM parts FROM parts
WHERE ipn LIKE ? WHERE ipn LIKE ?
OR mpn LIKE ? OR mpn LIKE ?
OR manufacturer LIKE ? OR manufacturer LIKE ?
OR description LIKE ? OR description LIKE ?
OR symbol LIKE ?
OR footprint LIKE ?
ORDER BY ipn ORDER BY ipn
LIMIT 100 LIMIT 100
""" """
search_param = f'%{search_query}%' search_param = f'%{search_query}%'
cursor.execute(sql, (search_param, search_param, search_param, search_param)) cursor.execute(sql, (search_param, search_param, search_param, search_param, search_param, search_param))
else: else:
# No search query - return first 100 parts # No search query - return first 100 parts
sql = """ sql = """
SELECT ipn, mpn, manufacturer, description SELECT ipn, mpn, manufacturer, description, datasheet
FROM parts FROM parts
ORDER BY ipn ORDER BY ipn
LIMIT 100 LIMIT 100
@@ -1632,10 +1638,11 @@ def handle_search_parts(data):
parts = [] parts = []
for row in rows: for row in rows:
parts.append({ parts.append({
'ipn': row.ipn if row.ipn else '', 'ipn': row[0] if row[0] else '',
'mpn': row.mpn if row.mpn else '', 'mpn': row[1] if row[1] else '',
'manufacturer': row.manufacturer if row.manufacturer else '', 'manufacturer': row[2] if row[2] else '',
'description': row.description if row.description else '' 'description': row[3] if row[3] else '',
'datasheet': row[4] if row[4] else ''
}) })
cursor.close() cursor.close()
@@ -1787,6 +1794,192 @@ def handle_create_part(data):
import traceback import traceback
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'}) emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
def resolve_library_path(lib_ref, lib_type='symbol'):
"""Resolve library reference to full path using KiCad library tables.
Args:
lib_ref: Library reference in format LibraryName:ItemName
lib_type: 'symbol' or 'footprint'
Returns:
tuple: (library_path, item_name, library_name) or (None, None, None) if not found
"""
try:
if ':' not in lib_ref:
return (None, None, None)
lib_name, item_name = lib_ref.split(':', 1)
# Get KiCad config directory
kicad_config_dir = os.path.expanduser(os.path.join('~', 'AppData', 'Roaming', 'kicad', '9.0'))
if lib_type == 'symbol':
table_file = os.path.join(kicad_config_dir, 'sym-lib-table')
else:
table_file = os.path.join(kicad_config_dir, 'fp-lib-table')
if not os.path.exists(table_file):
return (None, None, None)
# Parse the library table file
with open(table_file, 'r', encoding='utf-8') as f:
content = f.read()
# Simple s-expression parsing to find the library entry
# Handle both single-line and multi-line formats
# Pattern matches (lib ... (name "X") ... (uri "Y") ... )
import re
# Use DOTALL flag to match across newlines
pattern = r'\(lib\s+.*?\(name\s+"([^"]+)"\).*?\(uri\s+"([^"]+)"\).*?\)'
matches = re.findall(pattern, content, re.DOTALL)
for match_name, match_uri in matches:
if match_name == lib_name:
# Resolve environment variables in the path
lib_path = match_uri
# Replace common KiCad environment variables
env_vars = {
'${KICAD9_SYMBOL_DIR}': os.environ.get('KICAD9_SYMBOL_DIR', ''),
'${KICAD9_FOOTPRINT_DIR}': os.environ.get('KICAD9_FOOTPRINT_DIR', ''),
'${UM_KICAD}': os.environ.get('UM_KICAD', '')
}
for var, val in env_vars.items():
if var in lib_path and val:
lib_path = lib_path.replace(var, val)
# Convert to absolute path with proper separators
lib_path = os.path.normpath(lib_path.replace('/', os.sep))
if os.path.exists(lib_path):
return (lib_path, item_name, lib_name)
return (None, None, None)
except Exception as e:
print(f"Error resolving library path: {e}")
return (None, None, None)
@socketio.on('get_part_details')
def handle_get_part_details(data):
try:
ipn = data.get('ipn', '').strip()
if not ipn:
emit('library_error', {'error': 'IPN is required'})
return
conn = get_db_connection()
if not conn:
emit('library_error', {'error': 'Could not connect to database'})
return
cursor = conn.cursor()
cursor.execute("""
SELECT ipn, manufacturer, mpn, description, class, datasheet, symbol, footprint
FROM parts WHERE ipn = ?
""", (ipn,))
row = cursor.fetchone()
cursor.close()
conn.close()
if not row:
emit('library_error', {'error': f'Part {ipn} not found'})
return
part = {
'ipn': row[0],
'manufacturer': row[1] or '',
'mpn': row[2] or '',
'description': row[3] or '',
'class': row[4] or '',
'datasheet': row[5] or '',
'symbol': row[6] or '',
'footprint': row[7] or ''
}
# Resolve symbol and footprint library paths
symbol_info = None
footprint_info = None
if part['symbol']:
symbol_path, symbol_name, symbol_lib = resolve_library_path(part['symbol'], 'symbol')
if symbol_path and symbol_name:
symbol_info = {
'library_path': symbol_path,
'symbol_name': symbol_name,
'library_name': symbol_lib
}
if part['footprint']:
footprint_path, footprint_name, footprint_lib = resolve_library_path(part['footprint'], 'footprint')
if footprint_path and footprint_name:
footprint_info = {
'library_path': footprint_path,
'footprint_name': footprint_name,
'library_name': footprint_lib
}
emit('part_details_result', {
'part': part,
'symbol_info': symbol_info,
'footprint_info': footprint_info
})
except Exception as e:
import traceback
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
@socketio.on('update_part')
def handle_update_part(data):
try:
ipn = data.get('ipn', '').strip()
manufacturer = data.get('manufacturer', '').strip()
mpn = data.get('mpn', '').strip()
description = data.get('description', '').strip()
class_value = data.get('class', '').strip()
datasheet = data.get('datasheet', '').strip()
symbol = data.get('symbol', '').strip()
footprint = data.get('footprint', '').strip()
if not ipn:
emit('library_error', {'error': 'IPN is required'})
return
conn = get_db_connection()
if not conn:
emit('library_error', {'error': 'Could not connect to database'})
return
cursor = conn.cursor()
# Check if part exists
cursor.execute("SELECT ipn FROM parts WHERE ipn = ?", (ipn,))
if not cursor.fetchone():
emit('library_error', {'error': f'Part {ipn} not found in database'})
cursor.close()
conn.close()
return
# Update part
cursor.execute("""
UPDATE parts
SET manufacturer = ?, mpn = ?, description = ?, class = ?, datasheet = ?, symbol = ?, footprint = ?
WHERE ipn = ?
""", (manufacturer, mpn, description, class_value, datasheet, symbol, footprint, ipn))
conn.commit()
cursor.close()
conn.close()
emit('part_updated', {'message': f'Successfully updated part {ipn}'})
except Exception as e:
import traceback
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
@socketio.on('get_symbol_libraries') @socketio.on('get_symbol_libraries')
def handle_get_symbol_libraries(): def handle_get_symbol_libraries():
try: try:

View File

@@ -411,20 +411,24 @@
</div> </div>
</div> </div>
<!-- Create Part View --> <!-- Create/Edit Part View -->
<div id="libraryCreateView" style="display: none;"> <div id="libraryCreateView" style="display: none;">
<div style="margin: 20px 0; padding: 20px; background: #f8f9fa; border-radius: 8px;"> <div style="margin: 20px 0; padding: 20px; background: #f8f9fa; border-radius: 8px;">
<h3>Create New Part</h3> <h3 id="formTitle">Create New Part</h3>
<div id="createPartFormLoading" style="padding: 20px; text-align: center;"> <div id="createPartFormLoading" style="padding: 20px; text-align: center;">
<p>Loading missing IPNs from spreadsheet...</p> <p>Loading missing IPNs from spreadsheet...</p>
</div> </div>
<div id="createPartForm" style="display: none;"> <div id="createPartForm" style="display: none;">
<div style="margin-bottom: 15px;"> <div id="ipnDropdownContainer" style="margin-bottom: 15px;">
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Select IPN:</label> <label style="display: block; font-weight: bold; margin-bottom: 5px;">Select IPN:</label>
<select id="ipnDropdown" style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px;"> <select id="ipnDropdown" style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px;">
<option value="">-- Select an IPN --</option> <option value="">-- Select an IPN --</option>
</select> </select>
</div> </div>
<div id="ipnDisplayContainer" style="margin-bottom: 15px; display: none;">
<label style="display: block; font-weight: bold; margin-bottom: 5px;">IPN:</label>
<input type="text" id="ipnDisplay" readonly style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px; background-color: #e9ecef;">
</div>
<div style="margin-bottom: 15px;"> <div style="margin-bottom: 15px;">
<label style="display: block; font-weight: bold; margin-bottom: 5px;">Description:</label> <label style="display: block; font-weight: bold; margin-bottom: 5px;">Description:</label>
<input type="text" id="descriptionInput" readonly style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px; background-color: #e9ecef;"> <input type="text" id="descriptionInput" readonly style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-size: 14px; background-color: #e9ecef;">
@@ -1003,18 +1007,26 @@
<th style="padding: 10px; text-align: left; border: 1px solid #dee2e6;">MPN</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;">Manufacturer</th>
<th style="padding: 10px; text-align: left; border: 1px solid #dee2e6;">Description</th> <th style="padding: 10px; text-align: left; border: 1px solid #dee2e6;">Description</th>
<th style="padding: 10px; text-align: center; border: 1px solid #dee2e6;">Datasheet</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
`; `;
for (const part of data.parts) { for (const part of data.parts) {
const datasheetCell = part.datasheet
? `<a href="${part.datasheet}" target="_blank" style="color: #007bff; text-decoration: none;">Datasheet</a>`
: '';
html += ` html += `
<tr style="border-bottom: 1px solid #dee2e6;"> <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;">
<a href="#" onclick="editPart('${part.ipn}'); return false;" style="color: #007bff; text-decoration: none;">${part.ipn}</a>
</td>
<td style="padding: 8px; border: 1px solid #dee2e6;">${part.mpn}</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.manufacturer}</td>
<td style="padding: 8px; border: 1px solid #dee2e6;">${part.description}</td> <td style="padding: 8px; border: 1px solid #dee2e6;">${part.description}</td>
<td style="padding: 8px; text-align: center; border: 1px solid #dee2e6;">${datasheetCell}</td>
</tr> </tr>
`; `;
} }
@@ -1060,6 +1072,13 @@
let selectedFootprintLibraryPath = ''; let selectedFootprintLibraryPath = '';
let selectedSymbol = ''; let selectedSymbol = '';
let selectedFootprint = ''; let selectedFootprint = '';
let isEditMode = false;
let editingIpn = '';
const formTitle = document.getElementById('formTitle');
const ipnDropdownContainer = document.getElementById('ipnDropdownContainer');
const ipnDisplayContainer = document.getElementById('ipnDisplayContainer');
const ipnDisplay = document.getElementById('ipnDisplay');
function showCreatePartMessage(text, type) { function showCreatePartMessage(text, type) {
createPartMessage.textContent = text; createPartMessage.textContent = text;
@@ -1067,8 +1086,39 @@
createPartMessage.style.display = 'block'; createPartMessage.style.display = 'block';
} }
// Edit part function (called from search results)
window.editPart = function(ipn) {
isEditMode = true;
editingIpn = ipn;
formTitle.textContent = 'Edit Part';
ipnDropdownContainer.style.display = 'none';
ipnDisplayContainer.style.display = 'block';
ipnDisplay.value = ipn;
createPartBtn.textContent = 'Update Part';
libraryBrowserView.style.display = 'none';
libraryCreateView.style.display = 'block';
createPartFormLoading.style.display = 'block';
createPartForm.style.display = 'none';
createPartMessage.style.display = 'none';
// Load symbol and footprint libraries
socket.emit('get_symbol_libraries');
socket.emit('get_footprint_libraries');
// Request part details
socket.emit('get_part_details', { ipn: ipn });
};
// Show create part view // Show create part view
showCreatePartBtn.addEventListener('click', () => { showCreatePartBtn.addEventListener('click', () => {
isEditMode = false;
editingIpn = '';
formTitle.textContent = 'Create New Part';
ipnDropdownContainer.style.display = 'block';
ipnDisplayContainer.style.display = 'none';
createPartBtn.textContent = 'Create Part';
libraryBrowserView.style.display = 'none'; libraryBrowserView.style.display = 'none';
libraryCreateView.style.display = 'block'; libraryCreateView.style.display = 'block';
createPartFormLoading.style.display = 'block'; createPartFormLoading.style.display = 'block';
@@ -1275,24 +1325,41 @@
}); });
createPartBtn.addEventListener('click', () => { createPartBtn.addEventListener('click', () => {
const selectedIpn = ipnDropdown.value; if (isEditMode) {
if (!selectedIpn) return; // Edit mode - update existing part
createPartBtn.disabled = true;
showCreatePartMessage('Updating part in database...', 'info');
socket.emit('update_part', {
ipn: editingIpn,
description: descriptionInput.value.trim(),
class: classInput.value.trim(),
manufacturer: manufacturerInput.value.trim(),
mpn: mpnInput.value.trim(),
datasheet: datasheetInput.value.trim(),
symbol: selectedSymbol,
footprint: selectedFootprint
});
} else {
// Create mode - add new part
const selectedIpn = ipnDropdown.value;
if (!selectedIpn) return;
const part = missingPartsData.find(p => p.ipn === selectedIpn); const part = missingPartsData.find(p => p.ipn === selectedIpn);
if (!part) return; if (!part) return;
createPartBtn.disabled = true; createPartBtn.disabled = true;
showCreatePartMessage('Creating part in database...', 'info'); showCreatePartMessage('Creating part in database...', 'info');
socket.emit('create_part', { socket.emit('create_part', {
ipn: part.ipn, ipn: part.ipn,
description: part.description, description: descriptionInput.value.trim(),
class: part.class, class: classInput.value.trim(),
manufacturer: part.manufacturer, manufacturer: manufacturerInput.value.trim(),
mpn: part.mpn, mpn: mpnInput.value.trim(),
datasheet: datasheetInput.value.trim(), datasheet: datasheetInput.value.trim(),
symbol: selectedSymbol, symbol: selectedSymbol,
footprint: selectedFootprint footprint: selectedFootprint
}); });
}
}); });
socket.on('part_created', (data) => { socket.on('part_created', (data) => {
@@ -1334,6 +1401,71 @@
} }
}); });
socket.on('part_updated', (data) => {
showCreatePartMessage(data.message, 'success');
createPartBtn.disabled = false;
// Return to browser view after delay
setTimeout(() => {
libraryCreateView.style.display = 'none';
libraryBrowserView.style.display = 'block';
}, 1500);
});
socket.on('part_details_result', (data) => {
createPartFormLoading.style.display = 'none';
createPartForm.style.display = 'block';
// Populate form with part details
descriptionInput.value = data.part.description || '';
classInput.value = data.part.class || '';
manufacturerInput.value = data.part.manufacturer || '';
mpnInput.value = data.part.mpn || '';
datasheetInput.value = data.part.datasheet || '';
// Use resolved symbol info from server
if (data.symbol_info) {
selectedSymbol = data.part.symbol;
const libPath = data.symbol_info.library_path;
const symbolName = data.symbol_info.symbol_name;
// Find and select the library by path
const lib = symbolLibraries.find(l => l.path === libPath);
if (lib) {
symbolLibrarySelect.value = lib.path;
selectedSymbolLibraryPath = lib.path;
socket.emit('get_symbols_in_library', { library_path: lib.path });
// Symbol will be selected when symbols_in_library_result is received
setTimeout(() => {
symbolSelect.value = symbolName;
socket.emit('render_symbol', { library_path: lib.path, symbol_name: symbolName });
}, 500);
}
}
// Use resolved footprint info from server
if (data.footprint_info) {
selectedFootprint = data.part.footprint;
const libPath = data.footprint_info.library_path;
const footprintName = data.footprint_info.footprint_name;
// Find and select the library by path
const lib = footprintLibraries.find(l => l.path === libPath);
if (lib) {
footprintLibrarySelect.value = lib.path;
selectedFootprintLibraryPath = lib.path;
socket.emit('get_footprints_in_library', { library_path: lib.path });
// Footprint will be selected when footprints_in_library_result is received
setTimeout(() => {
footprintSelect.value = footprintName;
socket.emit('render_footprint', { library_path: lib.path, footprint_name: footprintName });
}, 500);
}
}
createPartBtn.disabled = false;
});
// ======================================== // ========================================
// Build Pipeline // Build Pipeline
// ======================================== // ========================================