master
Colby Knox 0.9.260: reveal falls back to a win32 filter set (library name only, no Enter) since eeschema's embedded Python binds m… ed4a185 2mo ago
"""Handler for install_library_bundle: install an Adom wiki Cloud Library
bundle (one zip: <Lib>.kicad_sym + <Lib>.pretty/ + 3dmodels/) in one verb.

This is the no-AI lane: a wiki page stages the zip over the web-control
transport (write_file / write_file_chunk) and calls this verb; the whole
symbol + footprint + 3D-model install happens here, reusing the proven
install_library paths (including the 0.9.251 model-ref rewrite)."""

import re
import shutil
import tempfile
import zipfile
from pathlib import Path
import sys

sys.path.insert(0, str(Path(__file__).parent.parent))

from handlers.install_library import handle_install_library, _install_footprint

MAX_ZIP_BYTES = 256 * 1024 * 1024
MAX_UNPACKED_BYTES = 512 * 1024 * 1024

# Library names become file/dir names and lib-table entries; keep them tame.
_NAME_OK = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$")


def _safe_extract(zf: zipfile.ZipFile, dest: Path) -> int:
    """Extract with a zip-slip guard and an unpacked-size cap. Returns the
    number of files extracted."""
    total = 0
    count = 0
    dest = dest.resolve()
    for info in zf.infolist():
        name = info.filename
        if not name or name.endswith("/"):
            continue
        # Reject absolute paths, drive letters, parent escapes, backslash tricks.
        norm = name.replace("\\", "/")
        if norm.startswith("/") or re.match(r"^[A-Za-z]:", norm) or ".." in norm.split("/"):
            raise ValueError(f"unsafe zip entry: {name}")
        total += info.file_size
        if total > MAX_UNPACKED_BYTES:
            raise ValueError("bundle unpacks past the size cap")
        target = (dest / norm).resolve()
        if not str(target).startswith(str(dest)):
            raise ValueError(f"unsafe zip entry: {name}")
        target.parent.mkdir(parents=True, exist_ok=True)
        with zf.open(info) as src, open(target, "wb") as out:
            shutil.copyfileobj(src, out)
        count += 1
    return count


def _find_bundle_parts(root: Path):
    """Locate the bundle pieces regardless of a wrapping top-level folder:
    the first *.kicad_sym (shallowest wins), its sibling *.pretty dir, and a
    sibling 3dmodels dir."""
    syms = sorted(root.rglob("*.kicad_sym"), key=lambda p: (len(p.parts), str(p)))
    if not syms:
        return None, None, None
    sym = syms[0]
    base = sym.parent
    pretty = None
    for d in sorted(base.iterdir()):
        if d.is_dir() and d.suffix == ".pretty":
            pretty = d
            break
    models = base / "3dmodels"
    return sym, pretty, (models if models.is_dir() else None)


def _top_symbol_names(sym_path: Path) -> list:
    """Top-level symbol names only (sub-units are named NAME_<n>_<n>)."""
    try:
        text = sym_path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return []
    names = re.findall(r'\(symbol\s+"([^"]+)"', text)
    return [n for n in names if not re.search(r"_\d+_\d+$", n)]


def _capture_proof(kicad_info: dict, args: dict, lib_name: str, first_symbol: str, result: dict):
    """Proof frame for the browser caller: open the Symbol Editor on the
    freshly installed library IN THE BACKGROUND (the show path's contract)
    and grab the shot. The editor is LEFT OPEN, loaded on the new library
    with the tree filtered to its name, so the user who surfaces KiCad sees
    their list immediately (Colby, 2026-08-27: closing it made the install
    feel unverified). It stays BEHIND the user's windows, never foregrounded.
    Pass closeEditor:true for the old close-after behavior. Best effort: any
    failure lands in proofSkipped, never in the install's success."""
    try:
        from handlers.show import handle_show_symbol
        show = handle_show_symbol(kicad_info, {
            "libraryName": lib_name,
            **({"symbolName": first_symbol} if first_symbol else {}),
            "capture": True,
            "captureMaxWidth": args.get("captureMaxWidth", 480),
        })
        if show.get("shot"):
            result["shot"] = show["shot"]
        else:
            result["proofSkipped"] = show.get("error") or show.get("shotUnavailable") or "no frame captured"
        if args.get("closeEditor"):
            if not show.get("reused"):
                try:
                    from handlers.close_windows import handle_close_symbol_editor
                    handle_close_symbol_editor(kicad_info, {})
                except Exception:
                    pass
        else:
            result["editorLeftOpen"] = True
            # Show the FULL list: clear the tree filter and select/expand the
            # library node by name (plugin 0.9.2 reveal_library; a text filter
            # made generic names like 'UI' lose a fuzzy match to other libs).
            _revealed = False
            try:
                import time as _rt
                from handlers.bridge_client import _resolve_plugin, call as _plugin_call
                # Plugin path first when it happens to be alive (node select,
                # generic-name-proof) -- but eeschema's embedded Python
                # initializes LAZILY (minutes after launch, issue #21), so on a
                # cold start this almost never binds in time. Short wait only.
                _deadline = _rt.monotonic() + 4
                info = err = None
                while _rt.monotonic() < _deadline:
                    info, err = _resolve_plugin("eeschema")
                    if info and err is None:
                        break
                    _rt.sleep(1.0)
                if info and err is None:
                    rev = _plugin_call(info["port"], "reveal_library",
                                       {"libraryName": lib_name}, timeout=20.0)
                    _revealed = bool((rev or {}).get("found"))
                    if _revealed:
                        result["libraryRevealed"] = "tree"
            except Exception:
                pass
            if not _revealed:
                # Plugin-free fallback (the normal cold-start path): WM_SETTEXT
                # the LIB_TREE filter to JUST the library name -- no Enter, so
                # the loaded symbol stays put and the tree shows the library's
                # full list. (A generic name like 'UI' can still fuzzy-match
                # other libraries above ours; only the plugin path fixes that.)
                try:
                    import ctypes
                    from handlers.kicad_ui import _find_all_kicad_windows
                    from handlers.open_symbol_editor import _find_child_by_class_and_parent
                    user32 = ctypes.windll.user32
                    hwnd = next((w.get("hwnd") for w in (_find_all_kicad_windows() or [])
                                 if "symbol editor" in (w.get("title") or "").lower()), None)
                    edit = _find_child_by_class_and_parent(hwnd, "Edit", "searchCtrl") if hwnd else None
                    if edit:
                        user32.SendMessageW(edit, 0x000C, 0, ctypes.create_unicode_buffer(lib_name))
                        result["libraryRevealed"] = "filter"
                    else:
                        result["libraryRevealed"] = False
                        result["revealDetail"] = "no symbol editor filter control found"
                except Exception as e:
                    result["libraryRevealed"] = False
                    result["revealDetail"] = f"{type(e).__name__}: {e}"
    except Exception as e:
        result["proofSkipped"] = str(e)


def handle_install_library_bundle(kicad_info: dict, args: dict) -> dict:
    if not kicad_info.get("installed"):
        return {
            "success": False,
            "error": "KiCad not installed",
            "_hint": "Install KiCad first (kicad_upgrade can do it), then retry.",
        }

    zip_path = args.get("zipPath", "")
    if not zip_path:
        return {"success": False, "error": "No zipPath specified",
                "_hint": "Stage the bundle zip with write_file / write_file_chunk first, then pass its path."}
    source = Path(zip_path).expanduser()
    if not source.is_file():
        return {"success": False, "error": f"Bundle zip not found: {zip_path}",
                "_hint": "The path must exist on THIS machine. Stage it with write_file / write_file_chunk first."}
    if source.stat().st_size > MAX_ZIP_BYTES:
        return {"success": False, "error": "Bundle zip is larger than the 256MB cap"}

    lib_name = args.get("libraryName", "") or source.stem
    lib_name = lib_name.strip()
    if not _NAME_OK.match(lib_name):
        return {"success": False, "error": f"Library name not usable as a KiCad library id: {lib_name!r}",
                "_hint": "Letters, digits, space, dot, dash, underscore; up to 64 chars."}

    extract_dir = Path(tempfile.mkdtemp(prefix="adom-lib-bundle-"))
    try:
        try:
            with zipfile.ZipFile(str(source)) as zf:
                extracted = _safe_extract(zf, extract_dir)
        except (zipfile.BadZipFile, ValueError, OSError) as e:
            return {"success": False, "error": f"Could not unpack the bundle: {e}"}
        if not extracted:
            return {"success": False, "error": "The bundle zip is empty"}

        sym, pretty, models = _find_bundle_parts(extract_dir)
        if not sym:
            return {"success": False, "error": "No .kicad_sym found in the bundle",
                    "_hint": "Expected an Adom wiki Cloud Library bundle: <Lib>.kicad_sym + <Lib>.pretty/ + 3dmodels/."}

        top_names = _top_symbol_names(sym)
        report = {"libraryName": lib_name, "symbols": len(top_names),
                  "footprints": 0, "modelsInstalled": 0, "modelRefsRewritten": 0}

        sym_res = handle_install_library(kicad_info, {
            "libraryPath": str(sym), "libraryType": "symbol", "libraryName": lib_name,
            "description": "Adom wiki Cloud Library",
        })
        if not sym_res.get("success"):
            return {"success": False, "error": f"Symbol install failed: {sym_res.get('error')}",
                    "step": "symbols", "detail": sym_res}

        # KiCad registers the file under its OWN name; a bundle whose inner file
        # is <Lib>.kicad_sym matches lib_name because the exporter names them
        # together. When they disagree, the installed copy keeps the file name
        # but the table entry uses lib_name (handle_install_library's contract).
        if pretty:
            fp_res = _install_footprint(kicad_info, {
                "libraryPath": str(pretty), "libraryName": lib_name,
                "modelsPath": str(models) if models else "",
                "description": "Adom wiki Cloud Library",
            })
            if not fp_res.get("success"):
                return {"success": False, "error": f"Footprint install failed: {fp_res.get('error')}",
                        "step": "footprints", "detail": fp_res, "symbolsInstalled": True}
            report["footprints"] = len(list(pretty.glob("*.kicad_mod")))
            fp_data = fp_res.get("data") or {}
            report["modelsInstalled"] = fp_data.get("modelsInstalled", 0)
            report["modelRefsRewritten"] = fp_data.get("modelRefsRewritten", 0)

        try:
            from handlers import win_focus
            kicad_running = bool(win_focus._any_kicad_process_running())
        except Exception:
            kicad_running = False
        report["kicadRunning"] = kicad_running
        report["restartNeeded"] = kicad_running

        parts = [f"Library '{lib_name}' installed: {report['symbols']} symbols"]
        if report["footprints"]:
            parts.append(f"{report['footprints']} footprints")
        if report["modelsInstalled"]:
            parts.append(f"{report['modelsInstalled']} 3D models")
        summary = ", ".join(parts)
        if kicad_running:
            summary += ". KiCad is running and reads library tables at startup, so restart KiCad to see it."

        result = {
            "success": True,
            "output": summary,
            "data": report,
            "_hint": (
                "The bundle is installed and registered in both lib tables. "
                + ("KiCad was RUNNING during the install: it reads sym-/fp-lib-table at startup only, "
                   "so the user must restart KiCad before the library appears. Never close their KiCad for them."
                   if kicad_running else
                   "KiCad was not running; the library appears on next launch. With capture:true the "
                   "Symbol Editor is left open IN THE BACKGROUND on the new library (tree filtered to "
                   "its name), so the user sees their parts the moment they surface KiCad.")
            ),
        }
        # Proof frame (capture:true): only when KiCad was NOT already running.
        # A running instance predates the lib-table registration, so an editor
        # there cannot see the new library; the restart note is the message.
        if args.get("capture"):
            if kicad_running:
                result["proofSkipped"] = "kicad_running: the open KiCad predates the install; restart to see the library"
            else:
                _capture_proof(kicad_info, args, lib_name, top_names[0] if top_names else "", result)
        return result
    finally:
        shutil.rmtree(str(extract_dir), ignore_errors=True)