Add ADG936BCPZ, OSD32MP157F-1G-BAA, TPS22965DSGR, AXP2101; add programmatic API and usage guide
- scripts/mikilab_lib.py / mikilab_cli.py: typed Python API + JSON CLI
for add/update/remove/query, for other apps to integrate without
shelling out to add_component.py
- lib_common.py/import_component.py/import_batch.py: regenerate
sym-lib-table.global/fp-lib-table.global on every mutating call, not
just the project-local tables
- scripts/find_missing_3d_models.py: lists components with no 3D model
and search links to fill the gap
- docs/GUIDA_USO.md: practical Italian usage guide (companion to README.md)
- checkup fixes: removed orphan duplicate footprint
footprints/other/CP_20_1_ADI.kicad_mod, and a dangling
${easyeda2kicad}/tmp 3D model reference in AXP2101's footprint that
pointed at a file that never existed on disk
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
find_missing_3d_models.py
|
||||
===========================
|
||||
|
||||
Reports every single-part MIKILAB component that has a footprint but no
|
||||
3D model, and prints ready-to-open search links for each one (SnapEDA,
|
||||
Octopart, UltraLibrarian) to speed up manually finding and importing the
|
||||
missing model via import_component.py --update (or mikilab_lib.update_component()).
|
||||
|
||||
This does not download or scrape anything -- SnapEDA/UltraLibrarian
|
||||
require a logged-in browser session to download a model, and Octopart's
|
||||
search UI sits behind a bot-detection challenge, so there is no reliable
|
||||
unattended way to fetch these. This script only narrows down *which*
|
||||
components need attention and *where* to look.
|
||||
|
||||
Bulk, multi-package category libraries (e.g. symbols/other/ti.kicad_sym,
|
||||
which pairs with a footprints/other/ti.pretty containing many unrelated
|
||||
footprints for many different chips) are excluded -- a single 3D model
|
||||
doesn't apply to a whole such library, only to one real part.
|
||||
|
||||
Usage:
|
||||
python3 scripts/find_missing_3d_models.py [--category CAT] [--csv OUTPUT.csv]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import mikilab_lib as mikilab
|
||||
|
||||
|
||||
def missing_3d_models(category: str | None = None) -> list[mikilab.ComponentInfo]:
|
||||
"""Single-part components (exactly one footprint file in their
|
||||
.pretty dir) that have a footprint but no 3D model."""
|
||||
return [
|
||||
c for c in mikilab.list_components(category=category)
|
||||
if c.footprint_path and not c.model_paths and len(c.footprint_files) == 1
|
||||
]
|
||||
|
||||
|
||||
def search_links(query: str) -> dict[str, str]:
|
||||
q = quote(query)
|
||||
return {
|
||||
"snapeda": f"https://www.snapeda.com/search/?q={q}&search-type=parts",
|
||||
"octopart": f"https://octopart.com/search?q={q}",
|
||||
"ultralibrarian": "https://app.ultralibrarian.com/search",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--category", choices=mikilab.lc.CATEGORIES)
|
||||
parser.add_argument("--csv", help="Also write the report to this CSV file")
|
||||
args = parser.parse_args()
|
||||
|
||||
missing = sorted(missing_3d_models(args.category), key=lambda c: (c.category, c.name))
|
||||
|
||||
if not missing:
|
||||
print("Nessun componente singolo senza modello 3D.")
|
||||
return 0
|
||||
|
||||
print(f"{len(missing)} componenti senza modello 3D:\n")
|
||||
|
||||
rows = []
|
||||
for c in missing:
|
||||
links = search_links(c.name)
|
||||
print(f"{c.category:15} {c.name}")
|
||||
print(f" footprint : {c.footprint_path}/{c.footprint_files[0]}.kicad_mod")
|
||||
print(f" SnapEDA : {links['snapeda']}")
|
||||
print(f" Octopart : {links['octopart']}")
|
||||
print(f" UltraLibrarian: {links['ultralibrarian']} (cerca a mano \"{c.name}\")")
|
||||
print()
|
||||
rows.append({
|
||||
"name": c.name,
|
||||
"category": c.category,
|
||||
"footprint": f"{c.footprint_path}/{c.footprint_files[0]}.kicad_mod",
|
||||
"snapeda_search": links["snapeda"],
|
||||
"octopart_search": links["octopart"],
|
||||
"ultralibrarian_search": links["ultralibrarian"],
|
||||
})
|
||||
|
||||
if args.csv:
|
||||
with open(args.csv, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
print(f"Scritto anche {args.csv}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -110,6 +110,7 @@ def main() -> int:
|
||||
|
||||
sym_entries = lc.write_sym_lib_table(ROOT)
|
||||
fp_entries = lc.write_fp_lib_table(ROOT)
|
||||
lc.write_global_tables(ROOT)
|
||||
|
||||
print(f"Batch import from {source}")
|
||||
print()
|
||||
@@ -118,6 +119,12 @@ def main() -> int:
|
||||
|
||||
print()
|
||||
print(f"Regenerated sym-lib-table ({len(sym_entries)} libraries) and fp-lib-table ({len(fp_entries)} libraries)")
|
||||
print("Regenerated sym-lib-table.global and fp-lib-table.global")
|
||||
print(
|
||||
"NOTE: if this library is registered globally in KiCad (README.md section 1), "
|
||||
"also re-run the merge step to update ~/Library/Preferences/kicad/*/sym-lib-table "
|
||||
"and fp-lib-table, or new components will not show up there."
|
||||
)
|
||||
|
||||
ok = sum(1 for _, status, _ in results if status == "OK")
|
||||
errors = sum(1 for _, status, _ in results if status == "ERROR")
|
||||
|
||||
@@ -273,7 +273,14 @@ def main() -> int:
|
||||
lc.append_manifest_rows(ROOT, manifest_rows)
|
||||
sym_entries = lc.write_sym_lib_table(ROOT)
|
||||
fp_entries = lc.write_fp_lib_table(ROOT)
|
||||
lc.write_global_tables(ROOT)
|
||||
report.append(f"Regenerated sym-lib-table ({len(sym_entries)} libraries) and fp-lib-table ({len(fp_entries)} libraries)")
|
||||
report.append("Regenerated sym-lib-table.global and fp-lib-table.global")
|
||||
report.append(
|
||||
"NOTE: if this library is registered globally in KiCad (README.md section 1), "
|
||||
"also re-run the merge step to update ~/Library/Preferences/kicad/*/sym-lib-table "
|
||||
"and fp-lib-table, or the removed component will still resolve there."
|
||||
)
|
||||
|
||||
print("\n".join(report))
|
||||
print("\nOK.")
|
||||
@@ -327,7 +334,14 @@ def main() -> int:
|
||||
|
||||
sym_entries = lc.write_sym_lib_table(ROOT)
|
||||
fp_entries = lc.write_fp_lib_table(ROOT)
|
||||
lc.write_global_tables(ROOT)
|
||||
report.append(f"Regenerated sym-lib-table ({len(sym_entries)} libraries) and fp-lib-table ({len(fp_entries)} libraries)")
|
||||
report.append("Regenerated sym-lib-table.global and fp-lib-table.global")
|
||||
report.append(
|
||||
"NOTE: if this library is registered globally in KiCad (README.md section 1), "
|
||||
"also re-run the merge step to update ~/Library/Preferences/kicad/*/sym-lib-table "
|
||||
"and fp-lib-table, or the new component will not show up there."
|
||||
)
|
||||
|
||||
print("\n".join(report))
|
||||
print("\nOK.")
|
||||
|
||||
@@ -239,6 +239,25 @@ def write_fp_lib_table(root: Path) -> list[LibEntry]:
|
||||
return entries
|
||||
|
||||
|
||||
def write_global_tables(root: Path, env_var: str = "MIKILAB") -> tuple[list[LibEntry], list[LibEntry]]:
|
||||
"""Regenerate sym-lib-table.global / fp-lib-table.global (the
|
||||
${MIKILAB}-based variants used when this library is registered
|
||||
globally in KiCad, across every project). Mirrors write_sym_lib_table
|
||||
/ write_fp_lib_table, which only cover the ${KIPRJMOD} project
|
||||
tables -- those alone are not enough for components to show up in
|
||||
KiCad when this library is used globally (see README.md section 1)."""
|
||||
sym_entries = build_sym_table_entries(root, env_var=env_var)
|
||||
fp_entries = build_fp_table_entries(root, env_var=env_var)
|
||||
|
||||
(root / "sym-lib-table.global").write_text(
|
||||
render_lib_table("sym_lib_table", sym_entries), encoding="utf-8"
|
||||
)
|
||||
(root / "fp-lib-table.global").write_text(
|
||||
render_lib_table("fp_lib_table", fp_entries), encoding="utf-8"
|
||||
)
|
||||
return sym_entries, fp_entries
|
||||
|
||||
|
||||
LIB_ENTRY_RE = re.compile(
|
||||
r'\(lib\s*\(name\s*"([^"]*)"\)\s*\(type\s*"([^"]*)"\)\s*'
|
||||
r'\(uri\s*"([^"]*)"\)\s*\(options\s*"([^"]*)"\)\s*'
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
mikilab_cli.py
|
||||
===============
|
||||
|
||||
Stable JSON-over-stdout CLI for mikilab_lib.py, for apps that are not
|
||||
Python (Swift, C++, ...) or that otherwise want to drive the MIKILAB
|
||||
library out-of-process instead of importing it directly.
|
||||
|
||||
Every invocation prints exactly one JSON object to stdout and nothing
|
||||
else (diagnostics, if any, go to stderr) -- safe to pipe straight into a
|
||||
JSON parser. Exit code is 0 iff "ok" is true.
|
||||
|
||||
On success:
|
||||
{"ok": true, "data": <command-specific object or array>}
|
||||
|
||||
On failure:
|
||||
{"ok": false, "error": "<human-readable message>"}
|
||||
|
||||
Commands:
|
||||
|
||||
add --name NAME --symbol PATH [--footprint PATH] [--model PATH] [--category CAT]
|
||||
update --name NAME --symbol PATH [--footprint PATH] [--model PATH] [--category CAT]
|
||||
remove --name NAME
|
||||
get --name NAME
|
||||
list [--category CAT]
|
||||
find --query TEXT [--category CAT]
|
||||
|
||||
Examples:
|
||||
|
||||
python3 scripts/mikilab_cli.py add --name TPS7A2018PDBVR \\
|
||||
--symbol /path/TPS7A2018PDBVR.kicad_sym \\
|
||||
--footprint /path/SOT95P280X145-5N.kicad_mod --category power
|
||||
|
||||
python3 scripts/mikilab_cli.py get --name TPS7A2018PDBVR
|
||||
|
||||
python3 scripts/mikilab_cli.py find --query tps22
|
||||
|
||||
From Swift: run as a subprocess (Process), read stdout, decode with
|
||||
JSONDecoder. From C++: popen()/posix_spawn + any JSON library (e.g.
|
||||
nlohmann::json). In both cases, check the process exit code as well as
|
||||
"ok" -- a non-zero exit always means "ok": false was printed (or, if the
|
||||
process crashed before printing anything, stdout will be empty).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import mikilab_lib as mikilab
|
||||
|
||||
|
||||
def _emit(payload: dict) -> int:
|
||||
print(json.dumps(payload, indent=2))
|
||||
return 0 if payload.get("ok") else 1
|
||||
|
||||
|
||||
def _ok(data) -> dict:
|
||||
if dataclasses.is_dataclass(data):
|
||||
data = dataclasses.asdict(data)
|
||||
elif isinstance(data, list):
|
||||
data = [dataclasses.asdict(x) if dataclasses.is_dataclass(x) else x for x in data]
|
||||
return {"ok": True, "data": data}
|
||||
|
||||
|
||||
def _err(message: str) -> dict:
|
||||
return {"ok": False, "error": message}
|
||||
|
||||
|
||||
def cmd_add(args) -> dict:
|
||||
result = mikilab.add_component(
|
||||
name=args.name, symbol=args.symbol, footprint=args.footprint,
|
||||
model=args.model, category=args.category,
|
||||
)
|
||||
return _ok(result)
|
||||
|
||||
|
||||
def cmd_update(args) -> dict:
|
||||
result = mikilab.update_component(
|
||||
name=args.name, symbol=args.symbol, footprint=args.footprint,
|
||||
model=args.model, category=args.category,
|
||||
)
|
||||
return _ok(result)
|
||||
|
||||
|
||||
def cmd_remove(args) -> dict:
|
||||
result = mikilab.remove_component(args.name)
|
||||
return _ok(result)
|
||||
|
||||
|
||||
def cmd_get(args) -> dict:
|
||||
result = mikilab.get_component(args.name)
|
||||
if result is None:
|
||||
return _err(f"no component named '{args.name}' found")
|
||||
return _ok(result)
|
||||
|
||||
|
||||
def cmd_list(args) -> dict:
|
||||
return _ok(mikilab.list_components(category=args.category))
|
||||
|
||||
|
||||
def cmd_find(args) -> dict:
|
||||
return _ok(mikilab.find_components(args.query, category=args.category))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="JSON CLI over mikilab_lib.py")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_add = sub.add_parser("add", help="Add a new component")
|
||||
p_add.add_argument("--name", required=True)
|
||||
p_add.add_argument("--symbol", required=True)
|
||||
p_add.add_argument("--footprint")
|
||||
p_add.add_argument("--model")
|
||||
p_add.add_argument("--category", choices=mikilab.lc.CATEGORIES)
|
||||
p_add.set_defaults(func=cmd_add)
|
||||
|
||||
p_update = sub.add_parser("update", help="Replace an existing component in place (or create it)")
|
||||
p_update.add_argument("--name", required=True)
|
||||
p_update.add_argument("--symbol", required=True)
|
||||
p_update.add_argument("--footprint")
|
||||
p_update.add_argument("--model")
|
||||
p_update.add_argument("--category", choices=mikilab.lc.CATEGORIES)
|
||||
p_update.set_defaults(func=cmd_update)
|
||||
|
||||
p_remove = sub.add_parser("remove", help="Remove a component")
|
||||
p_remove.add_argument("--name", required=True)
|
||||
p_remove.set_defaults(func=cmd_remove)
|
||||
|
||||
p_get = sub.add_parser("get", help="Look up one component by exact name")
|
||||
p_get.add_argument("--name", required=True)
|
||||
p_get.set_defaults(func=cmd_get)
|
||||
|
||||
p_list = sub.add_parser("list", help="List components, optionally filtered by category")
|
||||
p_list.add_argument("--category", choices=mikilab.lc.CATEGORIES)
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
p_find = sub.add_parser("find", help="Case-insensitive substring search by name")
|
||||
p_find.add_argument("--query", required=True)
|
||||
p_find.add_argument("--category", choices=mikilab.lc.CATEGORIES)
|
||||
p_find.set_defaults(func=cmd_find)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
payload = args.func(args)
|
||||
except mikilab.MikilabError as e:
|
||||
payload = _err(str(e))
|
||||
except Exception as e: # last-resort guard so callers always get valid JSON, never a traceback on stdout
|
||||
payload = _err(f"unexpected error: {e}")
|
||||
|
||||
return _emit(payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
mikilab_lib.py
|
||||
===============
|
||||
|
||||
Public Python API for managing the MIKILAB KiCad library: add, update,
|
||||
remove and query components. Meant to be imported directly by Python
|
||||
apps:
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/path/to/mikylab_kikad_library/scripts")
|
||||
import mikilab_lib as mikilab
|
||||
|
||||
result = mikilab.add_component(
|
||||
name="TPS7A2018PDBVR",
|
||||
symbol="/path/to/TPS7A2018PDBVR.kicad_sym",
|
||||
footprint="/path/to/SOT95P280X145-5N.kicad_mod",
|
||||
model="/path/to/TPS7A2018PDBVR.step",
|
||||
category="power",
|
||||
)
|
||||
|
||||
Non-Python apps (Swift, C++, ...) should drive this through
|
||||
mikilab_cli.py instead, which exposes the same operations as a stable
|
||||
JSON-over-stdout CLI (see that file's docstring).
|
||||
|
||||
This module does not reimplement any import/removal logic -- it is a
|
||||
thin, typed wrapper over import_component.py's existing functions (same
|
||||
validation, collision handling, and lib-table regeneration used by every
|
||||
other entry point in this repo: add_component.py, import_batch.py,
|
||||
import_snapeda.py, import_ultralibrarian.py). It only adds:
|
||||
- structured results/exceptions instead of print()+exit code,
|
||||
- query functions (get/list/find) that nothing else in this repo
|
||||
exposes today.
|
||||
|
||||
All mutating calls (add_component, update_component, remove_component)
|
||||
regenerate sym-lib-table, fp-lib-table, sym-lib-table.global and
|
||||
fp-lib-table.global before returning, and append rows to MANIFEST.csv --
|
||||
same guarantees as the CLI scripts. They do NOT touch the real KiCad
|
||||
global tables under ~/Library/Preferences/kicad/*/ -- if this library is
|
||||
registered globally in KiCad (README.md section 1), that merge step is
|
||||
still a separate, deliberate action (see README section 1, step 3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import lib_common as lc
|
||||
import import_component as ic
|
||||
|
||||
ROOT = lc.LIBRARY_ROOT
|
||||
|
||||
|
||||
class MikilabError(Exception):
|
||||
"""Raised for any library-management failure: bad component name,
|
||||
missing source file, unknown category, component not found (on
|
||||
remove/lookup), etc. str(e) is a human-readable message safe to show
|
||||
to a user or log as-is."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComponentResult:
|
||||
"""Result of a successful add_component/update_component/remove_component call."""
|
||||
|
||||
name: str
|
||||
category: str
|
||||
action: str # "add" | "update" | "remove"
|
||||
symbol_path: str | None = None
|
||||
footprint_path: str | None = None
|
||||
footprint_status: str | None = None # NEW / UPDATED / DUPLICATE / RENAMED_COLLISION
|
||||
model_path: str | None = None
|
||||
messages: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComponentInfo:
|
||||
"""One entry as returned by get_component/list_components/find_components.
|
||||
|
||||
Note: this reports one entry per .kicad_sym file under symbols/, which
|
||||
is the same unit add_component/remove_component operate on. A few
|
||||
library files (e.g. category-wide symbol libraries like LED.kicad_sym)
|
||||
contain multiple symbol definitions internally; this API does not look
|
||||
inside those, it reports the file as a single entry."""
|
||||
|
||||
name: str
|
||||
category: str
|
||||
symbol_path: str
|
||||
sym_nickname: str
|
||||
footprint_path: str | None
|
||||
fp_nickname: str | None
|
||||
footprint_files: list[str]
|
||||
model_paths: list[str]
|
||||
|
||||
|
||||
def _component_info(symbol_path: Path) -> ComponentInfo:
|
||||
name = symbol_path.stem
|
||||
category = symbol_path.parent.name
|
||||
|
||||
pretty_dir = ROOT / "footprints" / category / f"{name}.pretty"
|
||||
footprint_path = None
|
||||
fp_nickname = None
|
||||
footprint_files: list[str] = []
|
||||
if pretty_dir.is_dir():
|
||||
footprint_path = str(pretty_dir.relative_to(ROOT))
|
||||
fp_nickname = lc.fp_nickname(pretty_dir)
|
||||
footprint_files = sorted(p.stem for p in pretty_dir.glob("*.kicad_mod"))
|
||||
|
||||
models_dir = ROOT / "3dmodels" / category
|
||||
model_paths = []
|
||||
if models_dir.is_dir():
|
||||
model_paths = sorted(
|
||||
str(p.relative_to(ROOT))
|
||||
for p in models_dir.glob(f"{name}.*")
|
||||
if p.suffix.lower() in lc.MODEL_EXTENSIONS
|
||||
)
|
||||
|
||||
return ComponentInfo(
|
||||
name=name,
|
||||
category=category,
|
||||
symbol_path=str(symbol_path.relative_to(ROOT)),
|
||||
sym_nickname=lc.sym_nickname(symbol_path),
|
||||
footprint_path=footprint_path,
|
||||
fp_nickname=fp_nickname,
|
||||
footprint_files=footprint_files,
|
||||
model_paths=model_paths,
|
||||
)
|
||||
|
||||
|
||||
def _resolve(path: str | Path | None) -> Path | None:
|
||||
if path is None:
|
||||
return None
|
||||
return Path(path).expanduser().resolve()
|
||||
|
||||
|
||||
def _regenerate_tables() -> None:
|
||||
lc.write_sym_lib_table(ROOT)
|
||||
lc.write_fp_lib_table(ROOT)
|
||||
lc.write_global_tables(ROOT)
|
||||
|
||||
|
||||
def _import(
|
||||
name: str,
|
||||
symbol: str | Path,
|
||||
footprint: str | Path | None,
|
||||
model: str | Path | None,
|
||||
category: str | None,
|
||||
update: bool,
|
||||
action: str,
|
||||
) -> ComponentResult:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise MikilabError("name must not be empty")
|
||||
|
||||
symbol_path = _resolve(symbol)
|
||||
footprint_path = _resolve(footprint)
|
||||
model_path = _resolve(model)
|
||||
|
||||
for label, p in (("symbol", symbol_path), ("footprint", footprint_path), ("model", model_path)):
|
||||
if p is not None and not p.is_file():
|
||||
raise MikilabError(f"{label} does not exist or is not a file: {p}")
|
||||
|
||||
if model_path is not None and footprint_path is None:
|
||||
raise MikilabError("model requires footprint (a 3D model needs a footprint to attach to)")
|
||||
|
||||
resolved_category = category or lc.classify(name)
|
||||
if resolved_category not in lc.CATEGORIES:
|
||||
raise MikilabError(
|
||||
f"category '{resolved_category}' is not one of the known categories: "
|
||||
+ ", ".join(lc.CATEGORIES)
|
||||
)
|
||||
|
||||
manifest_rows: list[list[str]] = []
|
||||
report: list[str] = []
|
||||
|
||||
try:
|
||||
sym_dst = ic.import_symbol(name, resolved_category, symbol_path, manifest_rows, report, update=update)
|
||||
except ic.ImportError_ as e:
|
||||
lc.append_manifest_rows(ROOT, [["symbol", str(symbol_path), "", "ERROR", "", str(e)]])
|
||||
raise MikilabError(str(e)) from e
|
||||
|
||||
fp_dst = None
|
||||
fp_status = None
|
||||
model_dst = None
|
||||
|
||||
if footprint_path is not None:
|
||||
try:
|
||||
fp_dst, fp_nick, fp_name, fp_status = ic.import_footprint(
|
||||
name, resolved_category, footprint_path, manifest_rows, report, update=update
|
||||
)
|
||||
except ic.ImportError_ as e:
|
||||
lc.append_manifest_rows(ROOT, manifest_rows + [["footprint", str(footprint_path), "", "ERROR", "", str(e)]])
|
||||
raise MikilabError(str(e)) from e
|
||||
|
||||
if model_path is not None:
|
||||
model_target = fp_dst if fp_dst is not None else None
|
||||
ic.import_model(name, resolved_category, model_path, model_target, manifest_rows, report)
|
||||
model_dst = ROOT / "3dmodels" / resolved_category / f"{name}{model_path.suffix.lower()}"
|
||||
|
||||
text = sym_dst.read_text(encoding="utf-8")
|
||||
new_ref = f"{fp_nick}:{fp_name}"
|
||||
new_text, changed = lc.set_symbol_footprint_property(text, new_ref)
|
||||
if changed:
|
||||
sym_dst.write_text(new_text, encoding="utf-8")
|
||||
report.append(f" Linked symbol Footprint property -> {new_ref}")
|
||||
|
||||
lc.append_manifest_rows(ROOT, manifest_rows)
|
||||
_regenerate_tables()
|
||||
|
||||
return ComponentResult(
|
||||
name=name,
|
||||
category=resolved_category,
|
||||
action=action,
|
||||
symbol_path=str(sym_dst.relative_to(ROOT)),
|
||||
footprint_path=str(fp_dst.relative_to(ROOT)) if fp_dst is not None else None,
|
||||
footprint_status=fp_status,
|
||||
model_path=str(model_dst.relative_to(ROOT)) if model_dst is not None else None,
|
||||
messages=report,
|
||||
)
|
||||
|
||||
|
||||
def add_component(
|
||||
name: str,
|
||||
symbol: str | Path,
|
||||
footprint: str | Path | None = None,
|
||||
model: str | Path | None = None,
|
||||
category: str | None = None,
|
||||
) -> ComponentResult:
|
||||
"""Add a new component. Raises MikilabError if a component with this
|
||||
name already exists -- use update_component() to replace it in
|
||||
place instead."""
|
||||
return _import(name, symbol, footprint, model, category, update=False, action="add")
|
||||
|
||||
|
||||
def update_component(
|
||||
name: str,
|
||||
symbol: str | Path,
|
||||
footprint: str | Path | None = None,
|
||||
model: str | Path | None = None,
|
||||
category: str | None = None,
|
||||
) -> ComponentResult:
|
||||
"""Replace an existing component's symbol/footprint in place (upsert:
|
||||
if no component with this name exists yet, it is created, same as
|
||||
add_component()). `category` should normally match the component's
|
||||
current category -- this does not move files between category
|
||||
directories; use remove_component() + add_component() to
|
||||
recategorize."""
|
||||
return _import(name, symbol, footprint, model, category, update=True, action="update")
|
||||
|
||||
|
||||
def remove_component(name: str) -> ComponentResult:
|
||||
"""Remove a component: its symbol, its footprint library (if any),
|
||||
and its 3D model(s) (if any). Raises MikilabError if no component
|
||||
with this name exists."""
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise MikilabError("name must not be empty")
|
||||
|
||||
manifest_rows: list[list[str]] = []
|
||||
report: list[str] = []
|
||||
|
||||
try:
|
||||
ic.remove_component(name, manifest_rows, report)
|
||||
except ic.ImportError_ as e:
|
||||
raise MikilabError(str(e)) from e
|
||||
|
||||
category = next(
|
||||
(row[5].split("category=", 1)[1] for row in manifest_rows if row[0] == "symbol" and "category=" in row[5]),
|
||||
"",
|
||||
)
|
||||
|
||||
lc.append_manifest_rows(ROOT, manifest_rows)
|
||||
_regenerate_tables()
|
||||
|
||||
return ComponentResult(name=name, category=category, action="remove", messages=report)
|
||||
|
||||
|
||||
def get_component(name: str) -> ComponentInfo | None:
|
||||
"""Look up a component by exact name. Returns None if not found."""
|
||||
path = lc.find_symbol_by_name(ROOT, name.strip())
|
||||
if path is None:
|
||||
return None
|
||||
return _component_info(path)
|
||||
|
||||
|
||||
def list_components(category: str | None = None) -> list[ComponentInfo]:
|
||||
"""List every symbol library file under symbols/ (optionally filtered
|
||||
to one category). See ComponentInfo's docstring for what "one entry"
|
||||
means for multi-symbol category libraries."""
|
||||
if category is not None and category not in lc.CATEGORIES:
|
||||
raise MikilabError(
|
||||
f"category '{category}' is not one of the known categories: " + ", ".join(lc.CATEGORIES)
|
||||
)
|
||||
|
||||
entries = [_component_info(p) for p in lc.discover_symbol_libraries(ROOT)]
|
||||
if category is not None:
|
||||
entries = [e for e in entries if e.category == category]
|
||||
return entries
|
||||
|
||||
|
||||
def find_components(query: str, category: str | None = None) -> list[ComponentInfo]:
|
||||
"""Case-insensitive substring search over component names, optionally
|
||||
restricted to one category."""
|
||||
q = query.strip().lower()
|
||||
return [e for e in list_components(category) if q in e.name.lower()]
|
||||
Reference in New Issue
Block a user