diff --git a/docs/_static/smart_search.js b/docs/_static/smart_search.js new file mode 100644 index 000000000..d0e442fc8 --- /dev/null +++ b/docs/_static/smart_search.js @@ -0,0 +1,64 @@ +document.addEventListener("DOMContentLoaded", () => { + const form = document.getElementById("smartSearchForm"); + const input = document.getElementById("smartSearchInput"); + const status = document.getElementById("smartSearchStatus"); + + if (!form || !input) return; + + const URL_ROOT = + (window.DOCUMENTATION_OPTIONS && window.DOCUMENTATION_OPTIONS.URL_ROOT) || "./"; + + const apiIndexUrl = URL_ROOT + "_static/api_index.json"; + + async function loadIndex() { + const r = await fetch(apiIndexUrl, { cache: "no-store" }); + if (!r.ok) throw new Error(`Failed to load api_index.json (${r.status})`); + return await r.json(); + } + + function goToSearch(query) { + window.location.href = URL_ROOT + "search.html?q=" + encodeURIComponent(query); + } + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + + const q = (input.value || "").trim(); + if (!q) return; + + if (status) status.textContent = "Searching..."; + + try { + const idx = await loadIndex(); + + const key = q.toLowerCase(); + + // 1) Exact match by simple name (parsepdb -> parsePDB) + if (idx.exact_simple && idx.exact_simple[key]) { + window.location.href = URL_ROOT + idx.exact_simple[key]; + return; + } + + // 2) Exact match by full name if user pasted it + if (idx.exact_full && idx.exact_full[key]) { + window.location.href = URL_ROOT + idx.exact_full[key]; + return; + } + + // 3) No exact match -> normal Sphinx search (prefix search) + goToSearch(q); + } catch (err) { + console.error(err); + if (status) status.textContent = "Index not loaded; using normal search."; + goToSearch(q); + } + }); + + // Optional: make Enter always submit even if browser is weird + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + form.requestSubmit(); + } + }); +}); diff --git a/docs/conf.py b/docs/conf.py index 5efe33e52..ece31e375 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -73,6 +73,9 @@ # Ensure this is set so Sphinx looks in the _static folder html_static_path = ['_static'] +html_js_files = [ + "smart_search.js", +] # -- Extension Configuration ------------------------------------------------- @@ -100,3 +103,59 @@ 'scipy': ('https://docs.scipy.org/doc/scipy/', None), 'matplotlib': ('https://matplotlib.org/stable/', None), } +import os +import sys +import json +import os +from sphinx.util.inventory import InventoryFile + +def build_api_index(app, exception): + # If build failed, don't do anything + if exception is not None: + return + + inv_path = os.path.join(app.outdir, "objects.inv") + if not os.path.isfile(inv_path): + print("WARNING: objects.inv not found, skipping api_index.json generation") + return + + with open(inv_path, "rb") as f: + inv = InventoryFile.load(f, "", lambda base, uri: uri) + + # Map lowercase simple name -> URL + # Also keep full names in case you want them later + exact_simple = {} + exact_full = {} + + # Prefer functions over methods if there's a collision + role_priority = ["py:function", "py:method"] + + for role in role_priority: + if role not in inv: + continue + for fullname, data in inv[role].items(): + # data = (project, version, uri, dispname) + uri = data[2] + # Sphinx inventory sometimes uses "$" as placeholder for fullname + uri = uri.replace("$", fullname) + + simple = fullname.split(".")[-1].lower() + full_lower = fullname.lower() + + # fill full map always + exact_full[full_lower] = uri + + # fill simple map only if empty (function wins because we iterate functions first) + if simple not in exact_simple: + exact_simple[simple] = uri + + out_path = os.path.join(app.outdir, "_static", "api_index.json") + os.makedirs(os.path.dirname(out_path), exist_ok=True) + + with open(out_path, "w", encoding="utf-8") as f: + json.dump({"exact_simple": exact_simple, "exact_full": exact_full}, f) + + print(f"Wrote {out_path} with {len(exact_simple)} simple entries") + +def setup(app): + app.connect("build-finished", build_api_index) diff --git a/docs/index.rst b/docs/index.rst index a388f3b99..6fb6a0a20 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,8 +17,8 @@ ProDy Manual .. toctree:: :maxdepth: 2 - :glob: + smart_search getprody apps/index reference/index diff --git a/docs/smart_search.rst b/docs/smart_search.rst new file mode 100644 index 000000000..7ac8e5042 --- /dev/null +++ b/docs/smart_search.rst @@ -0,0 +1,19 @@ +Smart Search +============ + +Type a ProDy function name. + +- If it's an exact match (case-insensitive), you go directly to that function page. +- Otherwise, it falls back to normal search. + +.. raw:: html + +
+
+ + +
+

+
diff --git a/docs/tools/build_api_index.py b/docs/tools/build_api_index.py new file mode 100644 index 000000000..b3e9dbb11 --- /dev/null +++ b/docs/tools/build_api_index.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +import json +import os +import sys +import importlib +from pathlib import Path + + +def iter_module_names(pkg_name: str, pkg_root: Path): + """ + Walk the package directory on disk WITHOUT importing subpackages. + This avoids importing prody.tests.* which breaks on RTD. + """ + for py in pkg_root.rglob("*.py"): + # Skip caches / hidden + if "__pycache__" in py.parts: + continue + + rel = py.relative_to(pkg_root) + + # Skip tests entirely (this fixes your NRAS_BRAF_GDP_model_0.cif crash) + if "tests" in rel.parts: + continue + + # Build module name + parts = list(rel.parts) + if parts[-1] == "__init__.py": + parts = parts[:-1] + else: + parts[-1] = parts[-1].replace(".py", "") + + if not parts: + yield pkg_name + else: + yield pkg_name + "." + ".".join(parts) + + +def main(): + import prody + + pkg_name = "prody" + pkg_root = Path(prody.__file__).resolve().parent + + here = Path(__file__).resolve().parent.parent # docs/ + out_dir = here / "_static" + out_dir.mkdir(parents=True, exist_ok=True) + out_file = out_dir / "api_index.json" + + results = [] + + for modname in sorted(set(iter_module_names(pkg_name, pkg_root))): + try: + mod = importlib.import_module(modname) + except Exception: + # Some modules may require optional deps or side effects. + # We skip them so docs build doesn't fail. + continue + + for name, obj in vars(mod).items(): + if callable(obj) and getattr(obj, "__module__", "") == modname: + qual = f"{modname}.{name}" + results.append( + { + "name": name, # parsePDB + "qualname": qual, # prody.proteins.pdbfile.parsePDB + } + ) + + with out_file.open("w", encoding="utf-8") as f: + json.dump(results, f, indent=2) + + print(f"[smart_search] wrote {len(results)} entries to {out_file}") + + +if __name__ == "__main__": + main()