@jlcpcb/mcp 0.1.0
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.
- package/CHANGELOG.md +15 -0
- package/README.md +241 -0
- package/debug-text.ts +24 -0
- package/dist/assets/search.html +528 -0
- package/dist/index.js +32364 -0
- package/dist/src/index.js +28521 -0
- package/package.json +49 -0
- package/scripts/build-search-page.ts +68 -0
- package/src/assets/search-built.html +528 -0
- package/src/assets/search.html +458 -0
- package/src/browser/index.ts +381 -0
- package/src/browser/kicad-renderer.ts +646 -0
- package/src/browser/sexpr-parser.ts +321 -0
- package/src/http/routes.ts +253 -0
- package/src/http/server.ts +74 -0
- package/src/index.ts +117 -0
- package/src/tools/details.ts +66 -0
- package/src/tools/easyeda.ts +582 -0
- package/src/tools/index.ts +98 -0
- package/src/tools/library-fix.ts +414 -0
- package/src/tools/library-update.ts +412 -0
- package/src/tools/library.ts +263 -0
- package/src/tools/search.ts +58 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser app for EasyEDA Component Browser
|
|
3
|
+
* Handles search, rendering, and user interactions
|
|
4
|
+
*
|
|
5
|
+
* Uses KiCad S-expression renderer for symbol/footprint previews
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { renderSymbolSvg, renderFootprintSvg } from './kicad-renderer.js'
|
|
9
|
+
|
|
10
|
+
// Types
|
|
11
|
+
interface SearchResult {
|
|
12
|
+
uuid: string
|
|
13
|
+
title: string
|
|
14
|
+
thumb: string
|
|
15
|
+
description: string
|
|
16
|
+
tags: string[]
|
|
17
|
+
package: string
|
|
18
|
+
packageUuid?: string
|
|
19
|
+
manufacturer?: string
|
|
20
|
+
owner: {
|
|
21
|
+
uuid: string
|
|
22
|
+
username: string
|
|
23
|
+
nickname: string
|
|
24
|
+
avatar?: string
|
|
25
|
+
}
|
|
26
|
+
docType: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Pagination {
|
|
30
|
+
page: number
|
|
31
|
+
limit: number
|
|
32
|
+
total: number
|
|
33
|
+
totalPages: number
|
|
34
|
+
hasNext: boolean
|
|
35
|
+
hasPrev: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface SearchResponse {
|
|
39
|
+
results: SearchResult[]
|
|
40
|
+
pagination: Pagination
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Component data returned by the API
|
|
45
|
+
* Contains KiCad S-expression strings for rendering
|
|
46
|
+
*/
|
|
47
|
+
interface ComponentData {
|
|
48
|
+
uuid: string
|
|
49
|
+
title: string
|
|
50
|
+
description: string
|
|
51
|
+
symbolSexpr: string // KiCad symbol S-expression
|
|
52
|
+
footprintSexpr: string // KiCad footprint S-expression
|
|
53
|
+
model3d?: {
|
|
54
|
+
name: string
|
|
55
|
+
uuid: string
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// State
|
|
60
|
+
let currentPage = 1
|
|
61
|
+
let currentQuery = ''
|
|
62
|
+
let currentSource = 'user'
|
|
63
|
+
let isLoading = false
|
|
64
|
+
let debounceTimer: number | null = null
|
|
65
|
+
|
|
66
|
+
// DOM elements
|
|
67
|
+
const searchInput = document.getElementById('search-input') as HTMLInputElement
|
|
68
|
+
const sourceSelect = document.getElementById('source-select') as HTMLSelectElement
|
|
69
|
+
const searchBtn = document.getElementById('search-btn') as HTMLButtonElement
|
|
70
|
+
const resultsGrid = document.getElementById('results-grid') as HTMLDivElement
|
|
71
|
+
const paginationDiv = document.getElementById('pagination') as HTMLDivElement
|
|
72
|
+
const loadingDiv = document.getElementById('loading') as HTMLDivElement
|
|
73
|
+
const modal = document.getElementById('preview-modal') as HTMLDivElement
|
|
74
|
+
const modalContent = document.getElementById('modal-content') as HTMLDivElement
|
|
75
|
+
const modalClose = document.getElementById('modal-close') as HTMLButtonElement
|
|
76
|
+
|
|
77
|
+
// Initialize
|
|
78
|
+
function init() {
|
|
79
|
+
// Get query from URL
|
|
80
|
+
const params = new URLSearchParams(window.location.search)
|
|
81
|
+
const q = params.get('q')
|
|
82
|
+
if (q) {
|
|
83
|
+
searchInput.value = q
|
|
84
|
+
currentQuery = q
|
|
85
|
+
performSearch()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Event listeners
|
|
89
|
+
searchInput.addEventListener('input', handleSearchInput)
|
|
90
|
+
searchInput.addEventListener('keydown', (e) => {
|
|
91
|
+
if (e.key === 'Enter') {
|
|
92
|
+
e.preventDefault()
|
|
93
|
+
performSearch()
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
searchBtn.addEventListener('click', performSearch)
|
|
97
|
+
sourceSelect.addEventListener('change', () => {
|
|
98
|
+
currentSource = sourceSelect.value
|
|
99
|
+
if (currentQuery) performSearch()
|
|
100
|
+
})
|
|
101
|
+
modalClose.addEventListener('click', closeModal)
|
|
102
|
+
modal.addEventListener('click', (e) => {
|
|
103
|
+
if (e.target === modal) closeModal()
|
|
104
|
+
})
|
|
105
|
+
document.addEventListener('keydown', (e) => {
|
|
106
|
+
if (e.key === 'Escape') closeModal()
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Debounced search input
|
|
111
|
+
function handleSearchInput() {
|
|
112
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
113
|
+
debounceTimer = window.setTimeout(() => {
|
|
114
|
+
const query = searchInput.value.trim()
|
|
115
|
+
if (query && query !== currentQuery) {
|
|
116
|
+
currentQuery = query
|
|
117
|
+
currentPage = 1
|
|
118
|
+
performSearch()
|
|
119
|
+
}
|
|
120
|
+
}, 300)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Perform search
|
|
124
|
+
async function performSearch() {
|
|
125
|
+
const query = searchInput.value.trim()
|
|
126
|
+
if (!query || isLoading) return
|
|
127
|
+
|
|
128
|
+
currentQuery = query
|
|
129
|
+
isLoading = true
|
|
130
|
+
showLoading(true)
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const params = new URLSearchParams({
|
|
134
|
+
q: query,
|
|
135
|
+
source: currentSource,
|
|
136
|
+
page: String(currentPage),
|
|
137
|
+
limit: '20',
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const response = await fetch(`/api/search?${params}`)
|
|
141
|
+
if (!response.ok) throw new Error('Search failed')
|
|
142
|
+
|
|
143
|
+
const data: SearchResponse = await response.json()
|
|
144
|
+
renderResults(data.results)
|
|
145
|
+
renderPagination(data.pagination)
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error('Search error:', error)
|
|
148
|
+
resultsGrid.innerHTML = `<div class="error">Search failed. Please try again.</div>`
|
|
149
|
+
} finally {
|
|
150
|
+
isLoading = false
|
|
151
|
+
showLoading(false)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Render search results
|
|
156
|
+
function renderResults(results: SearchResult[]) {
|
|
157
|
+
if (results.length === 0) {
|
|
158
|
+
resultsGrid.innerHTML = `<div class="no-results">No components found. Try a different search term.</div>`
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
resultsGrid.innerHTML = results.map(result => `
|
|
163
|
+
<div class="card" data-uuid="${result.uuid}">
|
|
164
|
+
<div class="card-images">
|
|
165
|
+
<div class="image-container symbol-container" data-uuid="${result.uuid}">
|
|
166
|
+
<div class="symbol-placeholder">Loading...</div>
|
|
167
|
+
<div class="image-label">Symbol</div>
|
|
168
|
+
</div>
|
|
169
|
+
<div class="image-container footprint-container" data-uuid="${result.uuid}">
|
|
170
|
+
<div class="footprint-placeholder">Loading...</div>
|
|
171
|
+
<div class="image-label">Footprint</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
<div class="card-info">
|
|
175
|
+
<div class="card-title" title="${escapeHtml(result.title)}">${escapeHtml(result.title)}</div>
|
|
176
|
+
<div class="card-package">Package: ${escapeHtml(result.package || 'Unknown')}</div>
|
|
177
|
+
<div class="card-owner">By: ${escapeHtml(result.owner.nickname || result.owner.username)}</div>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="card-uuid">
|
|
180
|
+
<input type="text" value="${result.uuid}" readonly />
|
|
181
|
+
<button class="copy-btn" data-uuid="${result.uuid}" title="Copy UUID">
|
|
182
|
+
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
`).join('')
|
|
187
|
+
|
|
188
|
+
// Add copy button handlers
|
|
189
|
+
document.querySelectorAll('.copy-btn').forEach(btn => {
|
|
190
|
+
btn.addEventListener('click', (e) => {
|
|
191
|
+
e.stopPropagation()
|
|
192
|
+
const uuid = (btn as HTMLElement).dataset.uuid
|
|
193
|
+
if (uuid) copyToClipboard(uuid, btn as HTMLElement)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// Add hover handlers for enlarging
|
|
198
|
+
document.querySelectorAll('.image-container').forEach(container => {
|
|
199
|
+
container.addEventListener('click', async () => {
|
|
200
|
+
const uuid = (container as HTMLElement).dataset.uuid
|
|
201
|
+
if (uuid) showPreviewModal(uuid)
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// Load symbol and footprint previews for each card
|
|
206
|
+
results.forEach(result => loadComponentPreviews(result.uuid))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Load and render both symbol and footprint previews for a card
|
|
210
|
+
async function loadComponentPreviews(uuid: string) {
|
|
211
|
+
const symbolContainer = document.querySelector(`.symbol-container[data-uuid="${uuid}"]`)
|
|
212
|
+
const footprintContainer = document.querySelector(`.footprint-container[data-uuid="${uuid}"]`)
|
|
213
|
+
if (!symbolContainer && !footprintContainer) return
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const response = await fetch(`/api/component/${uuid}`)
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
if (symbolContainer) {
|
|
219
|
+
symbolContainer.innerHTML = '<div class="no-preview">Error</div><div class="image-label">Symbol</div>'
|
|
220
|
+
}
|
|
221
|
+
if (footprintContainer) {
|
|
222
|
+
footprintContainer.innerHTML = '<div class="no-preview">Error</div><div class="image-label">Footprint</div>'
|
|
223
|
+
}
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const data: ComponentData = await response.json()
|
|
228
|
+
|
|
229
|
+
// Render symbol from KiCad S-expression
|
|
230
|
+
if (symbolContainer) {
|
|
231
|
+
if (data.symbolSexpr) {
|
|
232
|
+
const svg = renderSymbolSvg(data.symbolSexpr)
|
|
233
|
+
symbolContainer.innerHTML = svg + '<div class="image-label">Symbol</div>'
|
|
234
|
+
} else {
|
|
235
|
+
symbolContainer.innerHTML = '<div class="no-preview">No preview</div><div class="image-label">Symbol</div>'
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Render footprint from KiCad S-expression
|
|
240
|
+
if (footprintContainer) {
|
|
241
|
+
if (data.footprintSexpr) {
|
|
242
|
+
const svg = renderFootprintSvg(data.footprintSexpr)
|
|
243
|
+
footprintContainer.innerHTML = svg + '<div class="image-label">Footprint</div>'
|
|
244
|
+
} else {
|
|
245
|
+
footprintContainer.innerHTML = '<div class="no-preview">No preview</div><div class="image-label">Footprint</div>'
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
if (symbolContainer) {
|
|
250
|
+
symbolContainer.innerHTML = '<div class="no-preview">Error</div><div class="image-label">Symbol</div>'
|
|
251
|
+
}
|
|
252
|
+
if (footprintContainer) {
|
|
253
|
+
footprintContainer.innerHTML = '<div class="no-preview">Error</div><div class="image-label">Footprint</div>'
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Show preview modal
|
|
259
|
+
async function showPreviewModal(uuid: string) {
|
|
260
|
+
modal.classList.remove('hidden')
|
|
261
|
+
|
|
262
|
+
modalContent.innerHTML = '<div class="modal-loading">Loading...</div>'
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const response = await fetch(`/api/component/${uuid}`)
|
|
266
|
+
if (!response.ok) throw new Error('Failed to fetch component')
|
|
267
|
+
|
|
268
|
+
const data: ComponentData = await response.json()
|
|
269
|
+
|
|
270
|
+
// Generate SVG previews from KiCad S-expressions
|
|
271
|
+
const symbolSvg = data.symbolSexpr ? renderSymbolSvg(data.symbolSexpr) : ''
|
|
272
|
+
const footprintSvg = data.footprintSexpr ? renderFootprintSvg(data.footprintSexpr) : ''
|
|
273
|
+
|
|
274
|
+
modalContent.innerHTML = `
|
|
275
|
+
<div class="modal-header">
|
|
276
|
+
<h2>${escapeHtml(data.title)}</h2>
|
|
277
|
+
<div class="modal-uuid">
|
|
278
|
+
<code>${data.uuid}</code>
|
|
279
|
+
<button class="copy-btn modal-copy" data-uuid="${data.uuid}">Copy UUID</button>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
<div class="modal-previews">
|
|
283
|
+
<div class="modal-preview">
|
|
284
|
+
<h3>Symbol (KiCad)</h3>
|
|
285
|
+
<div class="preview-svg">${symbolSvg || '<div class="no-preview">No symbol preview</div>'}</div>
|
|
286
|
+
</div>
|
|
287
|
+
<div class="modal-preview">
|
|
288
|
+
<h3>Footprint (KiCad)</h3>
|
|
289
|
+
<div class="preview-svg">${footprintSvg || '<div class="no-preview">No footprint preview</div>'}</div>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
<div class="modal-info">
|
|
293
|
+
<p><strong>Description:</strong> ${escapeHtml(data.description || 'N/A')}</p>
|
|
294
|
+
${data.model3d ? '<p><strong>3D Model:</strong> Available</p>' : ''}
|
|
295
|
+
</div>
|
|
296
|
+
`
|
|
297
|
+
|
|
298
|
+
// Add copy handler for modal
|
|
299
|
+
const modalCopyBtn = modalContent.querySelector('.modal-copy')
|
|
300
|
+
if (modalCopyBtn) {
|
|
301
|
+
modalCopyBtn.addEventListener('click', () => {
|
|
302
|
+
copyToClipboard(data.uuid, modalCopyBtn as HTMLElement)
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
} catch (error) {
|
|
306
|
+
console.error('Modal error:', error)
|
|
307
|
+
modalContent.innerHTML = '<div class="modal-error">Failed to load component details</div>'
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Close modal
|
|
312
|
+
function closeModal() {
|
|
313
|
+
modal.classList.add('hidden')
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Render pagination
|
|
317
|
+
function renderPagination(pagination: Pagination) {
|
|
318
|
+
if (pagination.totalPages <= 1) {
|
|
319
|
+
paginationDiv.innerHTML = ''
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
paginationDiv.innerHTML = `
|
|
324
|
+
<button class="page-btn" ${pagination.hasPrev ? '' : 'disabled'} data-page="${pagination.page - 1}">
|
|
325
|
+
← Prev
|
|
326
|
+
</button>
|
|
327
|
+
<span class="page-info">Page ${pagination.page} of ${pagination.totalPages}</span>
|
|
328
|
+
<button class="page-btn" ${pagination.hasNext ? '' : 'disabled'} data-page="${pagination.page + 1}">
|
|
329
|
+
Next →
|
|
330
|
+
</button>
|
|
331
|
+
`
|
|
332
|
+
|
|
333
|
+
paginationDiv.querySelectorAll('.page-btn').forEach(btn => {
|
|
334
|
+
btn.addEventListener('click', () => {
|
|
335
|
+
const page = parseInt((btn as HTMLElement).dataset.page || '1', 10)
|
|
336
|
+
if (page > 0) {
|
|
337
|
+
currentPage = page
|
|
338
|
+
performSearch()
|
|
339
|
+
window.scrollTo(0, 0)
|
|
340
|
+
}
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Copy to clipboard
|
|
346
|
+
async function copyToClipboard(text: string, btn: HTMLElement) {
|
|
347
|
+
try {
|
|
348
|
+
await navigator.clipboard.writeText(text)
|
|
349
|
+
btn.classList.add('copied')
|
|
350
|
+
setTimeout(() => btn.classList.remove('copied'), 1500)
|
|
351
|
+
} catch {
|
|
352
|
+
// Fallback for older browsers
|
|
353
|
+
const input = document.createElement('input')
|
|
354
|
+
input.value = text
|
|
355
|
+
document.body.appendChild(input)
|
|
356
|
+
input.select()
|
|
357
|
+
document.execCommand('copy')
|
|
358
|
+
document.body.removeChild(input)
|
|
359
|
+
btn.classList.add('copied')
|
|
360
|
+
setTimeout(() => btn.classList.remove('copied'), 1500)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Show/hide loading
|
|
365
|
+
function showLoading(show: boolean) {
|
|
366
|
+
loadingDiv.classList.toggle('hidden', !show)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Escape HTML
|
|
370
|
+
function escapeHtml(str: string): string {
|
|
371
|
+
const div = document.createElement('div')
|
|
372
|
+
div.textContent = str
|
|
373
|
+
return div.innerHTML
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Initialize on DOM ready
|
|
377
|
+
if (document.readyState === 'loading') {
|
|
378
|
+
document.addEventListener('DOMContentLoaded', init)
|
|
379
|
+
} else {
|
|
380
|
+
init()
|
|
381
|
+
}
|