Finish datasheet acquisition: more vendors, Mouser, browser download links.
Add Microchip/Murata/Silicon Labs URL tables, optional Mouser search, and suggested URLs on a miss so the wizard can open PDFs in the user's browser when the VPS is blocked. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -59,8 +59,8 @@ GCS_BUCKET=
|
||||
CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000","http://localhost:18742","http://127.0.0.1:18742"]
|
||||
|
||||
# -- DigiKey (optional) ------------------------------------------------------
|
||||
# Optional third datasheet source and parameter-based passive auto-resolve.
|
||||
# Datasheets are fetched from LCSC (no key) and TI first; DigiKey is extra.
|
||||
# Optional catalog datasheet source and parameter-based passive auto-resolve.
|
||||
# Order: BOM URL → LCSC → manufacturer PDF URLs → Mouser → DigiKey.
|
||||
# DIGIKEY_CLIENT_ID=
|
||||
# DIGIKEY_CLIENT_SECRET=
|
||||
# DIGIKEY_ENVIRONMENT=production
|
||||
@@ -68,6 +68,10 @@ CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:3000","http://localhost:
|
||||
# DIGIKEY_LOCALE_LANGUAGE=en
|
||||
# DIGIKEY_LOCALE_CURRENCY=USD
|
||||
|
||||
# -- Mouser (optional) -------------------------------------------------------
|
||||
# Search API key from mouser.com/api-hub. Same family/packing match as DigiKey.
|
||||
# MOUSER_API_KEY=
|
||||
|
||||
# -- Email notifications (optional) ------------------------------------------
|
||||
# Gmail API via domain-wide delegation. Leave EMAIL_SENDER empty to disable.
|
||||
# Service account credentials come from GOOGLE_APPLICATION_CREDENTIALS.
|
||||
|
||||
@@ -130,6 +130,9 @@ class Settings(BaseSettings):
|
||||
digikey_locale_language: str = "en"
|
||||
digikey_locale_currency: str = "USD"
|
||||
|
||||
# Mouser Search API (optional — fourth datasheet source)
|
||||
mouser_api_key: str = ""
|
||||
|
||||
# Purple Parts API (optional — converts LCSC codes to MPNs before DigiKey)
|
||||
purple_parts_url: str = ""
|
||||
purple_parts_api_key: str = ""
|
||||
@@ -194,6 +197,10 @@ class Settings(BaseSettings):
|
||||
def use_digikey(self) -> bool:
|
||||
return bool(self.digikey_client_id and self.digikey_client_secret)
|
||||
|
||||
@property
|
||||
def use_mouser(self) -> bool:
|
||||
return bool(self.mouser_api_key)
|
||||
|
||||
@property
|
||||
def use_purple_parts(self) -> bool:
|
||||
return bool(self.purple_parts_url and self.purple_parts_api_key)
|
||||
|
||||
@@ -755,7 +755,7 @@ async def make_collaborator_owner(
|
||||
async def fetch_auto_datasheet(mpn: str, request: Request, lcsc: str | None = None):
|
||||
"""Fetch a datasheet PDF for the given MPN.
|
||||
|
||||
Tries LCSC (no API key), Texas Instruments direct URLs, then DigiKey
|
||||
Tries LCSC (no API key), manufacturer PDF URLs, optional Mouser, then DigiKey
|
||||
if configured. ``/api/digikey/datasheet`` is kept as an alias.
|
||||
"""
|
||||
from backend.services.datasheet_finder import find_datasheet
|
||||
@@ -767,6 +767,7 @@ async def fetch_auto_datasheet(mpn: str, request: Request, lcsc: str | None = No
|
||||
content={
|
||||
"detail": result.error or "Failed to fetch datasheet",
|
||||
"url": result.url,
|
||||
"urls": result.suggested_urls or ([result.url] if result.url else []),
|
||||
"source": result.source,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -9,10 +9,15 @@ This module tries, in order:
|
||||
1. Explicit BOM datasheet URL (``url_hint``).
|
||||
2. LCSC product search (no API key) — exact MPN match, then packing-suffix
|
||||
variants (``/TR``, ``SPTR``, …).
|
||||
3. Direct manufacturer URLs (TI symlink + gpn, Espressif, ST, Analog,
|
||||
NXP, onsemi) with HTML-interstitial follow when the CDN returns a page
|
||||
instead of a PDF.
|
||||
4. DigiKey, if ``DIGIKEY_CLIENT_ID`` / ``SECRET`` are configured.
|
||||
3. Direct manufacturer URLs (TI, Espressif, ST, Analog, NXP, onsemi,
|
||||
Microchip, Murata, Silicon Labs) with HTML-interstitial follow when the
|
||||
CDN returns a page instead of a PDF.
|
||||
4. Mouser, if ``MOUSER_API_KEY`` is configured.
|
||||
5. DigiKey, if ``DIGIKEY_CLIENT_ID`` / ``SECRET`` are configured.
|
||||
|
||||
On a miss, ``suggested_urls`` lists every catalog/vendor link we found so
|
||||
the wizard can open them in the user's browser (datacenter IPs are often
|
||||
blocked).
|
||||
|
||||
Never raises: every failure is captured on :class:`DatasheetHit`.
|
||||
"""
|
||||
@@ -59,8 +64,9 @@ class DatasheetHit:
|
||||
pdf_bytes: bytes | None = None
|
||||
error: str | None = None
|
||||
url: str | None = None
|
||||
source: str | None = None # "lcsc" | "ti" | "digikey" | ...
|
||||
source: str | None = None # "lcsc" | "ti" | "mouser" | "digikey" | ...
|
||||
catalog_mpn: str | None = None # orderable code that actually matched
|
||||
suggested_urls: list[str] | None = None # browser-open links on a miss
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
@@ -524,6 +530,80 @@ def _onsemi_urls(mpn: str) -> list[str]:
|
||||
return [f"https://www.onsemi.com/pdf/datasheet/{slug}.pdf"]
|
||||
|
||||
|
||||
_MICROCHIP_PREFIXES = (
|
||||
"mcp", "pic16", "pic18", "pic24", "pic32", "dspic", "atsam", "atmega",
|
||||
"attiny", "24aa", "24lc", "24fc", "25aa", "25lc", "lan87", "lan74",
|
||||
"ksz", "usb25", "enc28",
|
||||
)
|
||||
|
||||
|
||||
def _microchip_family(mpn: str) -> str | None:
|
||||
s = mpn.lower()
|
||||
if not any(s.startswith(p) for p in _MICROCHIP_PREFIXES):
|
||||
return None
|
||||
token = re.split(r"[-/,]", s)[0]
|
||||
token = re.sub(r"[^a-z0-9]", "", token)
|
||||
return token or None
|
||||
|
||||
|
||||
def _microchip_urls(mpn: str) -> list[str]:
|
||||
fam = _microchip_family(mpn)
|
||||
if not fam:
|
||||
return []
|
||||
compact = re.sub(r"[^A-Za-z0-9]", "", mpn.split("/")[0].split(",")[0])
|
||||
return [
|
||||
f"https://www.microchip.com/en-us/product/{fam}",
|
||||
f"https://ww1.microchip.com/downloads/en/DeviceDoc/{compact}.pdf",
|
||||
f"https://ww1.microchip.com/downloads/en/DeviceDoc/{fam.upper()}.pdf",
|
||||
]
|
||||
|
||||
|
||||
_MURATA_PREFIXES = ("grm", "gqm", "gjm", "lqw", "lqm", "lqg", "blm", "nfm", "dlw", "nfe")
|
||||
|
||||
|
||||
def _murata_stem(mpn: str) -> str | None:
|
||||
s = mpn.strip().upper()
|
||||
if not any(s.startswith(p.upper()) for p in _MURATA_PREFIXES):
|
||||
return None
|
||||
stem = re.split(r"[/,]", s)[0]
|
||||
if stem.endswith("D") and len(stem) > 8:
|
||||
stem = stem[:-1]
|
||||
return stem
|
||||
|
||||
|
||||
def _murata_urls(mpn: str) -> list[str]:
|
||||
stem = _murata_stem(mpn)
|
||||
if not stem:
|
||||
return []
|
||||
raw = mpn.split("/")[0].split(",")[0].strip()
|
||||
return [
|
||||
f"https://search.murata.co.jp/Ceramy/image/img/A01X/G101/ENG/{stem}-01.pdf",
|
||||
f"https://www.murata.com/en-us/products/productdetail?partno={raw}",
|
||||
]
|
||||
|
||||
|
||||
_SILABS_PREFIXES = ("si4", "si5", "efr32", "cp21", "bgm", "wgm", "efm32")
|
||||
|
||||
|
||||
def _silabs_urls(mpn: str) -> list[str]:
|
||||
s = mpn.lower()
|
||||
if not any(s.startswith(p) for p in _SILABS_PREFIXES):
|
||||
return []
|
||||
compact = re.sub(r"[^A-Za-z0-9-]", "", mpn.split("/")[0].split(",")[0])
|
||||
parts = compact.split("-")
|
||||
family = parts[0]
|
||||
urls = [
|
||||
f"https://www.silabs.com/documents/public/data-sheets/{compact}.pdf",
|
||||
f"https://www.silabs.com/documents/public/data-sheets/{family}.pdf",
|
||||
]
|
||||
if len(parts) >= 2:
|
||||
urls.insert(
|
||||
1,
|
||||
f"https://www.silabs.com/documents/public/data-sheets/{parts[0]}-{parts[1]}.pdf",
|
||||
)
|
||||
return urls
|
||||
|
||||
|
||||
def manufacturer_pdf_candidates(mpn: str) -> list[tuple[str, str]]:
|
||||
"""Stable vendor PDF URLs for this MPN (source, url), first match wins."""
|
||||
out: list[tuple[str, str]] = []
|
||||
@@ -541,11 +621,32 @@ def manufacturer_pdf_candidates(mpn: str) -> list[tuple[str, str]]:
|
||||
add("analog", _adi_urls(mpn))
|
||||
add("nxp", _nxp_urls(mpn))
|
||||
add("onsemi", _onsemi_urls(mpn))
|
||||
add("microchip", _microchip_urls(mpn))
|
||||
add("murata", _murata_urls(mpn))
|
||||
add("silabs", _silabs_urls(mpn))
|
||||
return out
|
||||
|
||||
|
||||
def suggested_pdf_urls(mpn: str, *extras: str | None) -> list[str]:
|
||||
"""Deduped links the wizard can open in the user's browser."""
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(url: str | None) -> None:
|
||||
url = (url or "").strip()
|
||||
if url.startswith("http") and url not in seen:
|
||||
seen.add(url)
|
||||
out.append(url)
|
||||
|
||||
for extra in extras:
|
||||
add(extra)
|
||||
for _source, url in manufacturer_pdf_candidates(mpn):
|
||||
add(url)
|
||||
return out
|
||||
|
||||
|
||||
async def _from_manufacturer(mpn: str) -> DatasheetHit | None:
|
||||
"""Direct manufacturer datasheet URLs (TI symlink/gpn, ST, ADI, Espressif, …)."""
|
||||
"""Direct manufacturer datasheet URLs (TI, ST, ADI, Espressif, Microchip, …)."""
|
||||
last_err = None
|
||||
last_url = None
|
||||
last_source = None
|
||||
@@ -589,20 +690,36 @@ async def _from_digikey(mpn: str) -> DatasheetHit | None:
|
||||
return DatasheetHit(mpn, error=result.error, url=result.url, source="digikey")
|
||||
|
||||
|
||||
async def _from_mouser(mpn: str) -> DatasheetHit | None:
|
||||
if not settings.use_mouser:
|
||||
return None
|
||||
from backend.services.mouser import fetch_datasheet
|
||||
result = await fetch_datasheet(mpn)
|
||||
if result.ok:
|
||||
return DatasheetHit(
|
||||
mpn, pdf_bytes=result.pdf_bytes, url=result.url, source="mouser",
|
||||
catalog_mpn=getattr(result, "catalog_mpn", None),
|
||||
)
|
||||
if result.error or result.url:
|
||||
return DatasheetHit(mpn, error=result.error, url=result.url, source="mouser")
|
||||
return None
|
||||
|
||||
|
||||
async def find_datasheet(
|
||||
mpn: str, lcsc_id: str | None = None, url_hint: str | None = None,
|
||||
) -> DatasheetHit:
|
||||
"""Find and download a datasheet PDF for ``mpn``.
|
||||
|
||||
Tries an explicit BOM URL first, then LCSC, then manufacturer PDF
|
||||
URLs (TI, Espressif, ST, Analog, NXP, onsemi), then DigiKey.
|
||||
URLs, then Mouser, then DigiKey. A miss always includes
|
||||
``suggested_urls`` for a browser download.
|
||||
"""
|
||||
mpn = (mpn or "").strip()
|
||||
if not mpn:
|
||||
return DatasheetHit(mpn, error="Empty MPN")
|
||||
|
||||
errors: list[str] = []
|
||||
last_url: str | None = None
|
||||
found_urls: list[str] = []
|
||||
|
||||
hint = (url_hint or "").strip()
|
||||
if hint.startswith("http"):
|
||||
@@ -612,9 +729,9 @@ async def find_datasheet(
|
||||
except Exception as exc:
|
||||
log.info("BOM datasheet URL missed %s: %s", mpn, exc)
|
||||
errors.append(f"bom: {exc}")
|
||||
last_url = hint
|
||||
found_urls.append(hint)
|
||||
|
||||
for source_fn in (_from_lcsc, _from_ti, _from_digikey):
|
||||
for source_fn in (_from_lcsc, _from_ti, _from_mouser, _from_digikey):
|
||||
try:
|
||||
if source_fn is _from_lcsc:
|
||||
hit = await _from_lcsc(mpn, lcsc_id)
|
||||
@@ -631,7 +748,11 @@ async def find_datasheet(
|
||||
if hit.error:
|
||||
errors.append(f"{hit.source or source_fn.__name__}: {hit.error}")
|
||||
if hit.url:
|
||||
last_url = hit.url
|
||||
found_urls.append(hit.url)
|
||||
|
||||
urls = suggested_pdf_urls(mpn, *found_urls)
|
||||
detail = "; ".join(errors) if errors else "No datasheet found"
|
||||
return DatasheetHit(mpn, error=detail, url=last_url)
|
||||
return DatasheetHit(
|
||||
mpn, error=detail, url=urls[0] if urls else None,
|
||||
suggested_urls=urls,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Mouser Search API — optional fourth datasheet source (same MPN matching as DigiKey)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.datasheet_finder import (
|
||||
_alnum,
|
||||
_download_pdf,
|
||||
mpn_catalog_match,
|
||||
mpn_matches,
|
||||
mpn_query_variants,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEARCH_URL = "https://api.mouser.com/api/v1/search/partnumber"
|
||||
|
||||
|
||||
class MouserFetchResult:
|
||||
def __init__(
|
||||
self,
|
||||
mpn: str,
|
||||
pdf_bytes: bytes | None = None,
|
||||
error: str | None = None,
|
||||
url: str | None = None,
|
||||
catalog_mpn: str | None = None,
|
||||
):
|
||||
self.mpn = mpn
|
||||
self.pdf_bytes = pdf_bytes
|
||||
self.error = error
|
||||
self.url = url
|
||||
self.catalog_mpn = catalog_mpn
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.pdf_bytes is not None
|
||||
|
||||
|
||||
def _product_mpn(product: dict) -> str:
|
||||
return (
|
||||
product.get("ManufacturerPartNumber")
|
||||
or product.get("MouserPartNumber")
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def _product_ds(product: dict) -> str:
|
||||
url = product.get("DataSheetUrl") or product.get("DatasheetUrl") or ""
|
||||
if url.startswith("//"):
|
||||
url = "https:" + url
|
||||
return url
|
||||
|
||||
|
||||
def _pick_product(mpn: str, products: list[dict]) -> dict | None:
|
||||
exact: dict | None = None
|
||||
loose: dict | None = None
|
||||
family: dict | None = None
|
||||
want = _alnum(mpn)
|
||||
for product in products:
|
||||
cand = _product_mpn(product)
|
||||
if not cand:
|
||||
continue
|
||||
got = _alnum(cand)
|
||||
if got == want:
|
||||
exact = product
|
||||
break
|
||||
if loose is None and mpn_matches(mpn, cand):
|
||||
loose = product
|
||||
elif family is None and mpn_catalog_match(mpn, cand):
|
||||
family = product
|
||||
return exact or loose or family
|
||||
|
||||
|
||||
async def _keyword_search(mpn: str) -> list[dict]:
|
||||
key = settings.mouser_api_key
|
||||
payload = {
|
||||
"SearchByPartRequest": {
|
||||
"mouserPartNumber": mpn,
|
||||
"partSearchOptions": "Exact",
|
||||
}
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
resp = await client.post(
|
||||
_SEARCH_URL,
|
||||
params={"apiKey": key},
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
result = data.get("SearchResults") or {}
|
||||
return result.get("Parts") or []
|
||||
|
||||
|
||||
def pick_mouser_product(mpn: str, products: list[dict]) -> dict | None:
|
||||
"""Public for tests — same family/packing rules as DigiKey/LCSC."""
|
||||
return _pick_product(mpn, products)
|
||||
|
||||
|
||||
async def fetch_datasheet(mpn: str) -> MouserFetchResult:
|
||||
if not settings.use_mouser:
|
||||
return MouserFetchResult(mpn, error="Mouser API not configured")
|
||||
|
||||
product: dict | None = None
|
||||
last_err: str | None = None
|
||||
for query in mpn_query_variants(mpn):
|
||||
try:
|
||||
products = await _keyword_search(query)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
last_err = f"Mouser search failed ({exc.response.status_code})"
|
||||
logger.info("Mouser search %s: %s", query, last_err)
|
||||
continue
|
||||
except Exception as exc:
|
||||
last_err = f"Mouser search error: {exc}"
|
||||
logger.info("Mouser search %s: %s", query, last_err)
|
||||
continue
|
||||
product = _pick_product(mpn, products)
|
||||
if product:
|
||||
break
|
||||
|
||||
if not product:
|
||||
return MouserFetchResult(mpn, error=last_err or "No datasheet found on Mouser")
|
||||
|
||||
url = _product_ds(product)
|
||||
catalog = _product_mpn(product) or None
|
||||
if not url:
|
||||
return MouserFetchResult(
|
||||
mpn, error="No datasheet URL on Mouser", catalog_mpn=catalog,
|
||||
)
|
||||
|
||||
try:
|
||||
pdf = await _download_pdf(url, mpn=mpn)
|
||||
except Exception as exc:
|
||||
logger.info("Mouser PDF download failed for %s (%s): %s", mpn, url, exc)
|
||||
return MouserFetchResult(
|
||||
mpn, error=f"Mouser download failed: {exc}", url=url, catalog_mpn=catalog,
|
||||
)
|
||||
logger.info("Fetched datasheet for %s via Mouser (%d KB)", mpn, len(pdf) // 1024)
|
||||
return MouserFetchResult(mpn, pdf_bytes=pdf, url=url, catalog_mpn=catalog)
|
||||
@@ -475,6 +475,36 @@ function isEdifNetlist(text: string): boolean {
|
||||
return text.slice(0, 1024).trimStart().slice(0, 5).toLowerCase() === "(edif";
|
||||
}
|
||||
|
||||
function SuggestedDatasheetLinks({ urls }: { urls: string[] }) {
|
||||
if (!urls.length) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 mt-0.5">
|
||||
{urls.slice(0, 4).map((href) => {
|
||||
let host = href;
|
||||
try {
|
||||
host = new URL(href).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
return (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={href}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1 truncate max-w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">Open in browser · {host}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateProjectDialog({
|
||||
disabled,
|
||||
onCreateProject,
|
||||
@@ -567,6 +597,7 @@ export function CreateProjectDialog({
|
||||
const [fetchStatus, setFetchStatus] = useState<Map<string, FetchStatus>>(new Map());
|
||||
const [fetchErrors, setFetchErrors] = useState<Map<string, string>>(new Map());
|
||||
const [fetchUrls, setFetchUrls] = useState<Map<string, string>>(new Map());
|
||||
const [fetchUrlLists, setFetchUrlLists] = useState<Map<string, string[]>>(new Map());
|
||||
const [fetchSources, setFetchSources] = useState<Map<string, string>>(new Map());
|
||||
const [autoFetching, setAutoFetching] = useState(false);
|
||||
|
||||
@@ -890,6 +921,7 @@ export function CreateProjectDialog({
|
||||
setFetchStatus(new Map());
|
||||
setFetchErrors(new Map());
|
||||
setFetchUrls(new Map());
|
||||
setFetchUrlLists(new Map());
|
||||
setAutoFetching(false);
|
||||
setResolveStatus(new Map());
|
||||
setResolveErrors(new Map());
|
||||
@@ -1196,6 +1228,9 @@ export function CreateProjectDialog({
|
||||
if (e instanceof DatasheetFetchError && e.url) {
|
||||
setFetchUrls((prev) => new Map(prev).set(mpn, e.url!));
|
||||
}
|
||||
if (e instanceof DatasheetFetchError && e.urls.length) {
|
||||
setFetchUrlLists((prev) => new Map(prev).set(mpn, e.urls));
|
||||
}
|
||||
if (e instanceof DatasheetFetchError && e.source) {
|
||||
setFetchSources((prev) => new Map(prev).set(mpn, e.source!));
|
||||
}
|
||||
@@ -2421,7 +2456,7 @@ export function CreateProjectDialog({
|
||||
{failedCount} datasheet{failedCount !== 1 ? "s" : ""} couldn't download automatically.
|
||||
</p>
|
||||
<p className="text-amber-800/80 dark:text-amber-200/80 mt-0.5 leading-snug">
|
||||
Vendor sites sometimes block automated downloads. Retry, or on each failed row open the link if one is shown, save the PDF, then use the <span className="font-medium">PDF</span> upload button.
|
||||
Vendor sites sometimes block automated downloads from this server. Retry, or open a link on the failed row <span className="font-medium">in this browser</span>, save the PDF, then use the <span className="font-medium">PDF</span> upload button.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -2455,6 +2490,8 @@ export function CreateProjectDialog({
|
||||
const mpnFetchStatus = fetchStatus.get(mpn);
|
||||
const mpnFetchError = fetchErrors.get(mpn);
|
||||
const mpnFetchUrl = fetchUrls.get(mpn);
|
||||
const mpnFetchUrls = fetchUrlLists.get(mpn)
|
||||
?? (mpnFetchUrl ? [mpnFetchUrl] : []);
|
||||
// If this MPN came from an LCSC id, show the source id as
|
||||
// a muted prefix so the user can cross-check against
|
||||
// their BOM. Hidden when the BOM already had real MPNs.
|
||||
@@ -2484,7 +2521,10 @@ export function CreateProjectDialog({
|
||||
)}
|
||||
{mpn}
|
||||
</p>
|
||||
{mpnFetchUrl && (
|
||||
{mpnFetchStatus === "failed" && mpnFetchUrls.length > 0 && (
|
||||
<SuggestedDatasheetLinks urls={mpnFetchUrls} />
|
||||
)}
|
||||
{mpnFetchStatus !== "failed" && mpnFetchUrl && (
|
||||
<a
|
||||
href={mpnFetchUrl}
|
||||
target="_blank"
|
||||
@@ -2631,7 +2671,7 @@ export function CreateProjectDialog({
|
||||
{failedCount} datasheet{failedCount !== 1 ? "s" : ""} couldn't download automatically.
|
||||
</p>
|
||||
<p className="text-amber-800/80 dark:text-amber-200/80 mt-0.5 leading-snug">
|
||||
Vendor sites sometimes block automated downloads or return a non-PDF error page. These components are optional, but if you want a full review: click the <ExternalLink className="inline h-3 w-3 -mt-0.5" /> link on a failed row to open the datasheet, save the PDF, then use the <span className="font-medium">PDF</span> upload button on that row.
|
||||
Vendor sites sometimes block automated downloads or return a non-PDF error page. These components are optional, but if you want a full review: open a link <span className="font-medium">in this browser</span>, save the PDF, then use the <span className="font-medium">PDF</span> upload button.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2659,6 +2699,8 @@ export function CreateProjectDialog({
|
||||
const sFetchStatus = fetchStatus.get(mpn);
|
||||
const sFetchError = fetchErrors.get(mpn);
|
||||
const sFetchUrl = fetchUrls.get(mpn);
|
||||
const sFetchUrls = fetchUrlLists.get(mpn)
|
||||
?? (sFetchUrl ? [sFetchUrl] : []);
|
||||
const isResolved = rStatus === "resolved";
|
||||
const inProject =
|
||||
!file && !inLibrary && !isResolved && existingDatasheetStems.has(safeMpn(mpn));
|
||||
@@ -2677,19 +2719,10 @@ export function CreateProjectDialog({
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-mono text-sm font-medium truncate flex items-center gap-1">
|
||||
{mpn}
|
||||
{sFetchUrl && (
|
||||
<a
|
||||
href={sFetchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open datasheet"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
{sFetchStatus === "failed" && sFetchUrls.length > 0 && (
|
||||
<SuggestedDatasheetLinks urls={sFetchUrls} />
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{refs.join(", ")}
|
||||
</p>
|
||||
|
||||
@@ -934,12 +934,19 @@ export async function resolveLcscPassive(
|
||||
|
||||
export class DatasheetFetchError extends Error {
|
||||
url: string | null;
|
||||
urls: string[];
|
||||
source: string | null;
|
||||
constructor(message: string, url: string | null, source: string | null = null) {
|
||||
constructor(
|
||||
message: string,
|
||||
url: string | null,
|
||||
source: string | null = null,
|
||||
urls: string[] = [],
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DatasheetFetchError";
|
||||
this.url = url;
|
||||
this.source = source;
|
||||
this.urls = urls.length ? urls : url ? [url] : [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -963,6 +970,7 @@ export async function fetchAutoDatasheet(
|
||||
err.detail || "Failed to fetch datasheet",
|
||||
err.url ?? null,
|
||||
err.source ?? null,
|
||||
Array.isArray(err.urls) ? err.urls.filter((u: unknown) => typeof u === "string") : [],
|
||||
);
|
||||
}
|
||||
const url = res.headers.get("X-Datasheet-Url");
|
||||
|
||||
@@ -174,6 +174,42 @@ def test_manufacturer_candidates_ti_gpn_and_espressif():
|
||||
assert any("esp32-s31_datasheet_en.pdf" in u for u in urls)
|
||||
adi = manufacturer_pdf_candidates("ADAU1467WBCPZ300R")
|
||||
assert any(u.endswith("/ADAU1467.pdf") for _, u in adi)
|
||||
mcp = manufacturer_pdf_candidates("MCP23S17-E/ML")
|
||||
urls = [u for _, u in mcp]
|
||||
assert any("microchip.com/en-us/product/mcp23s17" in u for u in urls)
|
||||
murata = manufacturer_pdf_candidates("LQW18AN18NJ00D")
|
||||
urls = [u for _, u in murata]
|
||||
assert any("LQW18AN18NJ00-01.pdf" in u for u in urls)
|
||||
silabs = manufacturer_pdf_candidates("Si4684-A10-GM")
|
||||
urls = [u for _, u in silabs]
|
||||
assert any("Si4684-A10.pdf" in u for u in urls)
|
||||
|
||||
|
||||
def test_suggested_urls_on_miss(monkeypatch):
|
||||
async def miss(*args, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("backend.services.datasheet_finder._from_lcsc", miss)
|
||||
monkeypatch.setattr("backend.services.datasheet_finder._from_ti", miss)
|
||||
monkeypatch.setattr("backend.services.datasheet_finder._from_mouser", miss)
|
||||
monkeypatch.setattr("backend.services.datasheet_finder._from_digikey", miss)
|
||||
|
||||
hit = asyncio.run(find_datasheet("MCP23S17-E/ML"))
|
||||
assert not hit.ok
|
||||
assert hit.suggested_urls
|
||||
assert any("microchip.com" in u for u in hit.suggested_urls)
|
||||
|
||||
|
||||
def test_pick_mouser_family_orderable():
|
||||
from backend.services.mouser import pick_mouser_product
|
||||
|
||||
products = [
|
||||
{"ManufacturerPartNumber": "LAN8720A-CP-TR", "DataSheetUrl": "http://lan.pdf"},
|
||||
{"ManufacturerPartNumber": "OTHER", "DataSheetUrl": "http://no.pdf"},
|
||||
]
|
||||
picked = pick_mouser_product("LAN8720A", products)
|
||||
assert picked is not None
|
||||
assert picked["DataSheetUrl"] == "http://lan.pdf"
|
||||
|
||||
|
||||
def test_pdf_links_in_html_require_family_match():
|
||||
|
||||
Reference in New Issue
Block a user