diff --git a/doc/changelog.d/674.added.md b/doc/changelog.d/674.added.md new file mode 100644 index 000000000..dc1f18562 --- /dev/null +++ b/doc/changelog.d/674.added.md @@ -0,0 +1 @@ +add multi index searching and filtering \ No newline at end of file diff --git a/doc/source/_static/search_filter.png b/doc/source/_static/search_filter.png new file mode 100644 index 000000000..a84d1136f Binary files /dev/null and b/doc/source/_static/search_filter.png differ diff --git a/doc/source/conf.py b/doc/source/conf.py index 17027a8ff..578813218 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -14,9 +14,6 @@ from sphinx.builders.latex import LaTeXBuilder from ansys_sphinx_theme import ( - ALL_NODES, - PARAGRAPHS, - TITLES, __version__, ansys_favicon, ansys_logo_white, @@ -71,20 +68,24 @@ "version_match": get_version_match(__version__), }, "logo": "ansys", - "static_search": { - "threshold": 0.2, - "limit": 7, - "minMatchCharLength": 3, + "search_extra_sources": { + "PyDPF Core": "https:/dpf.docs.pyansys.com/version/stable/", + "Actions": "https://actions.docs.ansys.com/version/stable/", + }, + "search_filters": { + "User Guide": [ + "user-guide/", + "getting-started/", + "index/", + ], + "Release Notes": ["changelog"], + "Examples": ["examples/"], + "Contributing": ["contribute/"], }, } -html_js_files = ["https://cdn.plot.ly/plotly-3.0.1.min.js"] - -index_patterns = { - "examples/api/": ALL_NODES, - "examples/sphinx_examples/": TITLES + PARAGRAPHS, -} +html_js_files = ["https://cdn.plot.ly/plotly-3.0.1.min.js"] # Sphinx extensions diff --git a/doc/source/user-guide/options.rst b/doc/source/user-guide/options.rst index c4ed7b100..fffd0e13a 100644 --- a/doc/source/user-guide/options.rst +++ b/doc/source/user-guide/options.rst @@ -147,23 +147,6 @@ Here is an example of how to add the ``static_search`` dictionary to the }, } -To customise the indexing of your documentation, you can use the ``index_patterns`` dictionary in the ``conf.py`` file. -This dictionary contains the paths to the directories you want to index and the type of nodes to index. -The type of nodes can be ``ALL_NODES``, ``TITLES`` or ``PARAGRAPHS``. -The default value is ``TITLES + PARAGRAPHS``. - -Here is an example of how to add the ``index_patterns`` dictionary to the `conf.py`` file: - -.. code-block:: python - - index_patterns = { - "api/": ALL_NODES, - "examples/": TITLES + PARAGRAPHS, - } - -The above example indexes all nodes in the ``api/`` directory and only titles and paragraphs in the ``examples/`` directory. - - .. note:: All other options are available in the `Fuse.js documentation `_. @@ -203,6 +186,63 @@ Here is an example of how to add the ``static_search`` dictionary to the Then, open the browser and go to ``http://localhost:8000``. +Advanced search options +~~~~~~~~~~~~~~~~~~~~~~~ + +The Ansys Sphinx theme supports advanced search capabilities to enhance the user experience. + +These options can be configured through the ``html_theme_options`` dictionary in your ``conf.py`` file. + +Multi-index search +^^^^^^^^^^^^^^^^^^ + +To enable search across multiple documentation sources, use the ``search_extra_sources`` key. +This key should be assigned a list of dictionaries, where each dictionary represents an external index. +Each entry must contain a display name and the URL of the documentation to be included. + +**Example:** + +.. code-block:: python + + html_theme_options = { + "search_extra_sources": [ + {"name": "PyMAPDL", "url": "https://mapdl.docs.pyansys.com/version/stable/"}, + {"name": "PyAnsys", "url": "https://docs.pyansys.com/version/stable/"}, + ], + } + +Search filters +^^^^^^^^^^^^^^ + +To organize and group search results, you can define custom filters using the ``search_filters`` key. +This key should be a dictionary where each key represents a filter label and the corresponding value is a list of directories or file paths that belong to that filter. + +**Example:** + +.. code-block:: python + + html_theme_options = { + "search_filters": { + "User Guide": [ + "user-guide/", + "getting-started/", + "index/", + ], + "Release Notes": ["changelog"], + "Examples": ["examples/"], + "Contributing": ["contribute/"], + }, + } + +The filters appears as clickable options in the search interface, allowing users to refine their results by content type. + +The search filters are displayed as below: + +.. image:: ../_static/search_filter.png + :alt: Search filters + + + Cheat sheets ------------ diff --git a/package.json b/package.json index 36b82cfee..02b34019f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "sass": "^1.85.1" }, "dependencies": { + "idb": "^8.0.3", "postcss-scss": "^4.0.9" } } diff --git a/src/ansys_sphinx_theme/__init__.py b/src/ansys_sphinx_theme/__init__.py index 85b017903..9e74bd595 100644 --- a/src/ansys_sphinx_theme/__init__.py +++ b/src/ansys_sphinx_theme/__init__.py @@ -36,9 +36,6 @@ from ansys_sphinx_theme.extension.linkcode import DOMAIN_KEYS, sphinx_linkcode_resolve from ansys_sphinx_theme.latex import generate_404 from ansys_sphinx_theme.search import ( - ALL_NODES, - PARAGRAPHS, - TITLES, create_search_index, update_search_config, ) @@ -456,6 +453,36 @@ def add_sidebar_context( context["sidebars"] = sidebar +def update_search_sidebar_context( + app: Sphinx, pagename: str, templatename: str, context: dict, doctree: nodes.document +) -> None: + """Update the search sidebar context. + + This function updates the search sidebar context with the search index + and the search options. + + Parameters + ---------- + app : sphinx.application.Sphinx + Application instance for rendering the documentation. + pagename : str + Name of the current page. + templatename : str + Name of the template being used. + context : dict + Context dictionary for the page. + doctree : docutils.nodes.document + Document tree for the page. + """ + sidebar = context.get("sidebars", []) + if pagename == "search": + if "search_sidebar.html" not in sidebar: + sidebar.append("search_sidebar.html") + + # Update the sidebar context + context["sidebars"] = sidebar + + def append_og_site_name(app, pagename, templatename, context, doctree): # Make sure the context already has metatags context["metatags"] = context.get("metatags", "") @@ -511,6 +538,8 @@ def setup(app: Sphinx) -> Dict: app.connect("html-page-context", update_footer_theme) app.connect("html-page-context", fix_edit_html_page_context) app.connect("html-page-context", append_og_site_name) + app.connect("html-page-context", update_search_sidebar_context) + app.connect("build-finished", replace_html_tag) if use_ansys_search: app.connect("build-finished", create_search_index) @@ -521,4 +550,8 @@ def setup(app: Sphinx) -> Dict: } -__all__ = ["__version__", "generate_404", "get_version_match", "TITLES", "PARAGRAPHS", "ALL_NODES"] +__all__ = [ + "__version__", + "generate_404", + "get_version_match", +] diff --git a/src/ansys_sphinx_theme/assets/styles/ast-search.scss b/src/ansys_sphinx_theme/assets/styles/ast-search.scss index 6766c41e3..a55c6d9dc 100644 --- a/src/ansys_sphinx_theme/assets/styles/ast-search.scss +++ b/src/ansys_sphinx_theme/assets/styles/ast-search.scss @@ -35,9 +35,8 @@ /* Result Title */ .result-title { font-size: 1em; - font-weight: var(--ast-font-weight-bold); font-family: "Open Sans", sans-serif; - color: var(--ast-catagory-header-text); + color: var(--ast-color-link); } /* Result Text */ @@ -49,8 +48,8 @@ /* Highlighted Text */ html[data-theme="light"] .highlight { - color: var(--ast-highlight-color); - font-family: var(--ast-code-family); + color: var(--pst-color-text-base); + font-weight: var(--ast-font-weight-bold); } /* Search Input Styles */ @@ -166,4 +165,130 @@ html[data-theme="light"] .highlight { .bd-search .search-button__kbd-shortcut { display: none; } +} + + +/* ========== General Styles ========== */ + +.result-item { + padding: 8px; + margin-bottom: 4px; + cursor: pointer; +} + +.dropdown-menu.show { + display: block; +} + +.dropdown-item { + padding: 10px; + cursor: pointer; +} + +/* ========== Search Input ========== */ + +#search-input { + width: 100%; + max-width: 600px; /* Adjust as needed */ + padding: 10px; + margin-bottom: 10px; +} + +/* ========== Checkbox Filter Items ========== */ + +.checkbox-item { + display: flex; + align-items: center; + margin: 5px 10px; +} + + + +/* ========== Chips (Selected Filter Tags) ========== */ + +.chip { + display: inline-flex; + align-items: center; + background-color: var(--ast-hover-suggestion-text-background); + border-radius: 16px; + padding: 5px 10px; + margin: 5px; +} + +.search-bar-filter{ + display: flex; + align-items: center; + gap: 0.5rem; +} + +.chip .remove-btn { + margin-left: 8px; + background: none; + border: none; + color:var(--ast-search-bar-enable-text); + cursor: pointer; + font-weight: bold; +} + +.chip .remove-btn:hover { + color: var(--ast-search-bar-enable-background); +} + +/* Dropdown items inside chips if needed */ +.chip .dropdown-item { + padding: 5px 10px; + cursor: pointer; +} + +/* ========== Dropdown Panels (Sidebar) ========== */ + +#objectid-dropdown, +#library-dropdown { + position: relative; + top: 100%; /* Directly below parent */ + left: 10px; + background-color: transparent; + border: none; + display: block; +} + +.search-page-sidebar.toggle-section { + display: flex; + align-items: center; + cursor: pointer; + padding: 5px 10px; + border-radius: 5px; + margin-top: 5px; +} + +.search-page-sidebar.toggle-section.active { + background-color: var(--ast-hover-suggestion-text-background); + font-weight: bold; +} + +.toggle-icon { + padding: 8px; +} + + +.checkmark { + margin-right: 5px; + color: var(--ast-search-bar-enable-text); + font-weight: 400; +} + +.result-limit-wrapper { + display: flex; + align-items: center; + margin: 0 10px; + font-size: 0.875rem; /* 14px */ + color: var(--ast-search-bar-enable-text); +} + select { + background-color: var(--ast-search-bar-enable-background); + color: var(--ast-search-bar-enable-text); + border: 0.03125rem solid var(--ast-search-bar-enable-border); /* 0.5px */ + border-radius: 0.25rem; /* 4px */ + padding: 0.5rem; + font-size: 0.875rem; /* 14px */ } \ No newline at end of file diff --git a/src/ansys_sphinx_theme/search/__init__.py b/src/ansys_sphinx_theme/search/__init__.py index de0a64a7d..eac56f50f 100644 --- a/src/ansys_sphinx_theme/search/__init__.py +++ b/src/ansys_sphinx_theme/search/__init__.py @@ -24,10 +24,6 @@ from sphinx.application import Sphinx from ansys_sphinx_theme.search.fuse_search import ( - ALL_NODES, - LITERAL, - PARAGRAPHS, - TITLES, create_search_index, ) @@ -41,7 +37,7 @@ def update_search_config(app: Sphinx) -> None: Sphinx application. """ theme_static_options = app.config.html_theme_options.get("static_search", {}) - theme_static_options["keys"] = ["title", "text"] + theme_static_options["keys"] = ["title", "text", "objectID"] theme_static_options["threshold"] = theme_static_options.get("threshold", 0.2) theme_static_options["limit"] = theme_static_options.get("limit", 10) app.add_config_value("index_patterns", {}, "html") @@ -51,8 +47,4 @@ def update_search_config(app: Sphinx) -> None: __all__ = [ "create_search_index", "update_search_config", - "LITERAL", - "PARAGRAPHS", - "TITLES", - "ALL_NODES", ] diff --git a/src/ansys_sphinx_theme/search/fuse_search.py b/src/ansys_sphinx_theme/search/fuse_search.py index 0a58edf07..cf89e4319 100644 --- a/src/ansys_sphinx_theme/search/fuse_search.py +++ b/src/ansys_sphinx_theme/search/fuse_search.py @@ -27,19 +27,12 @@ import re from docutils import nodes -from docutils.nodes import Element - -PARAGRAPHS = [nodes.paragraph] -TITLES = [nodes.title] -LITERAL = [nodes.literal] -ALL_NODES = [nodes.Text] -DEFAULT_PATTERN = PARAGRAPHS + TITLES + LITERAL class SearchIndex: """Generate a search index for a Sphinx document.""" - def __init__(self, doc_name, app, pattern=None): + def __init__(self, doc_name, app, filter_options=None): """ Initialize the search index object. @@ -56,8 +49,15 @@ def __init__(self, doc_name, app, pattern=None): self.theme_options = app.config.html_theme_options.get("static_search", {}) self.doc_title = self.env.titles[self.doc_name].astext() self.doc_tree = self.env.get_doctree(self.doc_name) + first_part = self.doc_name.split("/")[0] + try: + self.parent_title = ( + self.env.titles.get(first_part).astext() if doc_name != "index" else "Home" + ) + except: # noqa: E722 + self.parent_title = "Home" self.sections = [] - self.pattern = pattern + self.filter_options = filter_options def build_sections(self): """Build sections from the document tree, handling subsections and descriptions.""" @@ -74,46 +74,28 @@ def build_sections(self): section_title = node[0].astext() - section_text = "\n".join( - n.astext() for node_type in self.pattern for n in node.traverse(node_type) + unwanted_types = ( + nodes.math, + nodes.raw, + nodes.image, + nodes.figure, + nodes.comment, + nodes.literal_block, ) - section_anchor_id = _title_to_anchor(section_title) - self.sections.append( - { - "title": section_title, - "text": section_text, - "anchor_id": section_anchor_id, - } - ) + # Collect all unwanted nodes first + unwanted_nodes = [n for n in node.traverse() if isinstance(n, unwanted_types)] + + for n in unwanted_nodes: + if n.parent: + n.parent.remove(n) + clean_text = node.astext() - self._process_desc_element(node, section_title) - - def _process_desc_element(self, node, title): - """Process `desc` element within a section.""" - for element in node.traverse(Element): - if element.tagname != "desc": - continue - - # id is the id tag of the desc element - section_anchor_id = element.attributes["ids"] - if element.children: - for element_child in element.children: - if element_child.tagname != "desc_signature": - continue - if element_child.attributes.get("ids"): - section_anchor_id = element_child.attributes["ids"][0] - section_text = element.astext() - if isinstance(section_anchor_id, list) and len(section_anchor_id) > 0: - section_anchor_id = section_anchor_id[0] - if section_anchor_id: - section_title = _desc_anchor_to_title(title, section_anchor_id) - else: - section_title = title + section_anchor_id = _title_to_anchor(section_title) self.sections.append( { "title": section_title, - "text": section_text, + "text": clean_text, "anchor_id": section_anchor_id, } ) @@ -163,35 +145,32 @@ def indices(self): """Generate indices for each section.""" for section in self.sections: breadcrumbs = self.generate_breadcrumbs(section["title"]) + self.object_id = filter_search_documents( + self.filter_options, self.doc_name, self.parent_title + ) yield { - "objectID": self.doc_name, + "objectID": self.object_id, "href": f"{self.doc_path}#{section['anchor_id']}", "title": breadcrumbs, - "section": section["title"], "text": section["text"], } -def _desc_anchor_to_title(title, anchor): - """Convert a desc anchor to a title.""" - anchor_title = anchor.split(".")[-1] - return f"{title} > {anchor_title}" - - def _title_to_anchor(title: str) -> str: """Convert a title to a URL-friendly anchor identifier.""" return re.sub(r"[^\w\s-]", "", title.lower().strip().replace(" ", "-")) -def get_pattern_for_each_page(app, doc_name): - """Get the pattern for each page in the search index.""" - patterns = app.env.config.index_patterns or {} - - for filename, pattern in patterns.items(): - if doc_name.startswith(filename): - return pattern - - return DEFAULT_PATTERN +def filter_search_documents(filters, doc_name, doc_title): + """Filter search documents based on the provided filters.""" + if not filters: + # get the first part of the document name and sentence case it + return doc_title + for key, values in filters.items(): + for value in values: + if doc_name.startswith(value.rstrip("/")): + return key + return doc_title def create_search_index(app, exception): @@ -213,6 +192,7 @@ def create_search_index(app, exception): static_search_options = app.config.html_theme_options.get("static_search", {}) excluded_docs = static_search_options.get("files_to_exclude", []) included_docs = app.env.found_docs + filter_options = app.config.html_theme_options.get("search_filters", {}) for exclude_doc in excluded_docs: exclude_doc = Path(exclude_doc).resolve() @@ -225,11 +205,10 @@ def create_search_index(app, exception): ] for document in included_docs: - pattern = get_pattern_for_each_page(app, document) - search_index = SearchIndex(document, app, pattern) + search_index = SearchIndex(document, app, filter_options=filter_options) search_index.build_sections() search_index_list.extend(search_index.indices) search_index_path = Path(app.builder.outdir) / "_static" / "search.json" with search_index_path.open("w", encoding="utf-8") as index_file: - json.dump(search_index_list, index_file, ensure_ascii=False, indent=4) + json.dump(search_index_list, index_file, ensure_ascii=False, separators=(",", ":")) diff --git a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/components/ast-search-button.html b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/components/ast-search-button.html index cd8c22c13..5a3468a07 100644 --- a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/components/ast-search-button.html +++ b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/components/ast-search-button.html @@ -4,7 +4,10 @@ // Passing the search options to the search.js file const SEARCH_OPTIONS = JSON.parse('{{ theme_static_search | tojson | safe }}'); const SEARCH_FILE = "{{ pathto('_static/search.json', 1) }}"; +const ADVANCE_SEARCH_PATH = "{{ pathto('search.html', 1) }}"; +const extra_sources = JSON.parse('{{ theme_search_extra_sources | tojson | safe }}'); - +{% if pagename != "search" %} +{% endif %} diff --git a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/components/search_sidebar.html b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/components/search_sidebar.html new file mode 100644 index 000000000..b40421c91 --- /dev/null +++ b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/components/search_sidebar.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/search.html b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/search.html new file mode 100644 index 000000000..f422ebb01 --- /dev/null +++ b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/search.html @@ -0,0 +1,34 @@ +{%- extends "page.html" %} + +{% block docs_body %} + + +
+

{{ _("Search") }}

+ +
+
+
+ + + + +{% endblock %} + +{%- block htmltitle -%} + {{ _("Search") }} - {{ title or docstitle }} +{%- endblock htmltitle -%} + diff --git a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/static/js/search-main.js b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/static/js/search-main.js new file mode 100644 index 000000000..502174a9d --- /dev/null +++ b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/static/js/search-main.js @@ -0,0 +1,465 @@ +const SEARCH_BAR = document.getElementById("search-bar"); +const SEARCH_INPUT = SEARCH_BAR.querySelector(".bd-search input.form-control"); + +require.config({ + paths: { + fuse: "https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min", + }, +}); + +/* IndexDD functions */ + +function openDB(name = "search-cache", version = 1) { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, version); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains("indexes")) { + db.createObjectStore("indexes"); + } + }; + }); +} + +async function getFromIDB(key) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction("indexes", "readonly"); + const store = tx.objectStore("indexes"); + const request = store.get(key); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function saveToIDB(key, value) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction("indexes", "readwrite"); + const store = tx.objectStore("indexes"); + const request = store.put(value, key); + request.onsuccess = () => resolve(true); + request.onerror = () => reject(request.error); + }); +} + +/** + * Initializes the search system by loading the document search index. + */ + +require(["fuse"], function (Fuse) { + let fuse; + let searchData = []; + let selectedObjectIDs = []; + let selectedLibraries = []; + const libSearchData = {}; + + let selectedFilter = new Set(); + function debounce(func, delay) { + let timeout; + return function (...args) { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), delay); + }; + } + + /** + * Initializes the search system by loading the document search index. + */ + async function initializeSearch() { + try { + const cacheKey = "main-search-index"; + let data = await getFromIDB(cacheKey); + + if (!data) { + const response = await fetch(SEARCH_FILE); + data = await response.json(); + await saveToIDB(cacheKey, data); + } + + searchData = data; + fuse = new Fuse(searchData, SEARCH_OPTIONS); + + const allLibs = Object.keys(extra_sources); + for (const lib of allLibs) { + const cacheKey = `lib-search-${lib}`; + let libData = await getFromIDB(cacheKey); + + if (!libData) { + const libPath = extra_sources[lib]; + const libJsonPath = `${libPath}/_static/search.json`; + const res = await fetch(libJsonPath); + libData = await res.json(); + await saveToIDB(cacheKey, libData); + } + + libSearchData[lib] = libData; // Save in memory + } + + setupFilterDropdown(); + showObjectIdDropdown(); + showLibraryDropdown(); + } catch (err) { + console.error("Search init failed", err); + } + } + + /** + * Sets up the filter dropdown and its toggle interactions. + */ + function setupFilterDropdown() { + const dropdownContainer = document.getElementById("search-sidebar"); + + const filters = [ + { + name: "Documents", + dropdownId: "objectid-dropdown", + callback: showObjectIdDropdown, + }, + { + name: "Library", + dropdownId: "library-dropdown", + callback: showLibraryDropdown, + }, + ]; + + // remove the library filter if no libraries + if (Object.keys(extra_sources).length === 0) { + filters.splice(1, 1); + } + + filters.forEach(({ name, dropdownId, callback }) => { + const toggleDiv = document.createElement("div"); + toggleDiv.className = "search-page-sidebar toggle-section"; + toggleDiv.dataset.target = dropdownId; + + const icon = document.createElement("span"); + icon.className = "toggle-icon"; + icon.textContent = "▼"; + icon.style.fontSize = "12px"; + + const label = document.createElement("span"); + label.className = "toggle-label"; + label.textContent = name; + + toggleDiv.append(icon, label); + + const dropdown = document.createElement("div"); + dropdown.id = dropdownId; + dropdown.className = "dropdown-menu show"; + dropdown.style.display = "block"; + dropdown.style.marginTop = "10px"; + + // Add event listener to toggle the dropdown + toggleDiv.addEventListener("click", () => { + const isVisible = dropdown.style.display === "block"; + dropdown.style.display = isVisible ? "none" : "block"; + icon.textContent = isVisible ? "▶" : "▼"; + + if (isVisible) { + selectedFilter.delete(name); + toggleDiv.classList.remove("active"); + } else { + selectedFilter.add(name); + toggleDiv.classList.add("active"); + callback?.(); + } + + performSearch(); + }); + + dropdownContainer.append(toggleDiv, dropdown); + }); + } + + function showObjectIdDropdown() { + const dropdown = document.getElementById("objectid-dropdown"); + dropdown.innerHTML = ""; + + const objectIDs = [ + ...new Set(searchData.map((item) => item.objectID)), + ].filter(Boolean); + + objectIDs.forEach((id) => { + const checkbox = createCheckboxItem(id, selectedObjectIDs, () => { + renderSelectedChips(); + performSearch(); + }); + dropdown.appendChild(checkbox); + }); + + dropdown.style.display = "block"; + renderSelectedChips(); + } + + function showLibraryDropdown() { + const dropdown = document.getElementById("library-dropdown"); + dropdown.innerHTML = ""; + + for (const lib in extra_sources) { + const checkbox = createCheckboxItem(lib, selectedLibraries, () => { + renderSelectedChips(); + performSearch(); + }); + dropdown.appendChild(checkbox); + } + + dropdown.style.display = "block"; + renderSelectedChips(); + } + + function createCheckboxItem(value, selectedArray, onchnage) { + const div = document.createElement("div"); + div.className = "checkbox-item"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.value = value; + checkbox.style.margin = "8px"; + checkbox.checked = selectedArray.includes(value); + checkbox.onchange = (e) => { + if (e.target.checked) { + selectedArray.push(value); + } else { + const index = selectedArray.indexOf(value); + if (index > -1) { + selectedArray.splice(index, 1); + } + } + onchnage(); + }; + + const label = document.createElement("label"); + label.textContent = value; + div.appendChild(checkbox); + div.appendChild(label); + return div; + } + + /** + * Renders chips for selected filters and binds remove logic. + */ + function renderSelectedChips() { + const container = document.getElementById("selected-chips"); + container.innerHTML = ""; + + const renderChip = (value, type, selectedArray) => { + const chip = document.createElement("div"); + chip.className = "chip"; + chip.textContent = `${value} (${type})`; + + const removeBtn = document.createElement("button"); + removeBtn.className = "remove-btn"; + removeBtn.innerHTML = "×"; + removeBtn.onclick = () => { + const index = selectedArray.indexOf(value); + if (index !== -1) selectedArray.splice(index, 1); + renderSelectedChips(); + if (type === "Documents") showObjectIdDropdown(); + if (type === "Library") showLibraryDropdown(); + performSearch(); + }; + + chip.appendChild(removeBtn); + container.appendChild(chip); + }; + + selectedObjectIDs.forEach((id) => + renderChip(id, "Documents", selectedObjectIDs), + ); + selectedLibraries.forEach((lib) => + renderChip(lib, "Library", selectedLibraries), + ); + } + + async function performSearch() { + const query = document.getElementById("search-input").value.trim(); + if (!fuse) return; + + const resultsContainer = document.getElementById("search-results"); + resultsContainer.innerHTML = "Searching..."; + + let docResults = []; + let libResults = []; + + const resultLimit = getSelectedResultLimit(); + + // === Search in internal documents === + if (selectedFilter.size === 0 || selectedFilter.has("Documents")) { + docResults = fuse + .search(query, { limit: resultLimit }) + .map((r) => r.item); + + if (selectedObjectIDs.length > 0) { + docResults = docResults.filter((item) => + selectedObjectIDs.includes(item.objectID), + ); + } + } + + for (const lib of selectedLibraries) { + const libBaseUrl = extra_sources[lib]; + const cacheKey = `lib-search-${lib}`; + + try { + const data = await getFromIDB(cacheKey); // Use cached data + + if (data) { + const enrichedEntries = data.map((entry) => ({ + title: entry.title, + text: entry.text, + section: entry.section, // if used in keys + link: `${libBaseUrl}${entry.href}`, + source: lib, + })); + + // Create a separate Fuse instance for this library + const libFuse = new Fuse(enrichedEntries, SEARCH_OPTIONS); + + // Search and add to results (append instead of overwrite) + const results = libFuse + .search(query, { limit: resultLimit }) + .map((r) => r.item); + + libResults.push(...results); + } + } catch (err) { + console.error(`Error accessing cache for ${lib}:`, err); + } + } + + // === Merge and show results === + const mergedResults = [...docResults, ...libResults]; + + if (mergedResults.length === 0) { + resultsContainer.innerHTML = "

No results found.

"; + return; + } + + const highlightedResults = highlightResults(mergedResults, query); + displayResults(highlightedResults); + } + + function highlightResults(results, query) { + const regex = new RegExp(`(${query})`, "gi"); + + return results + .map((result) => { + const matchIndex = result.text + .toLowerCase() + .indexOf(query.toLowerCase()); + if (matchIndex === -1) return null; + + const contextLength = 100; + const start = Math.max(0, matchIndex - contextLength); + const end = Math.min(result.text.length, matchIndex + contextLength); + let snippet = result.text.slice(start, end); + + if (start > 0) snippet = "…" + snippet; + if (end < result.text.length) snippet += "…"; + + return { + ...result, + title: result.title.replace( + regex, + `$1`, + ), + text: snippet.replace(regex, `$1`), + }; + }) + .filter(Boolean); + } + + /** + * Displays the final search results on the UI. + */ + function displayResults(results) { + const container = document.getElementById("search-results"); + container.innerHTML = ""; + + results.forEach((item) => { + const div = document.createElement("div"); + div.className = "result-item"; + + const title = document.createElement("a"); + title.href = item.href || item.link || "#"; + title.target = "_blank"; + title.innerHTML = item.title || "Untitled"; + title.className = "result-title"; + + div.appendChild(title); + + if (item.text) { + const text = document.createElement("p"); + text.innerHTML = item.text; + text.className = "result-text"; + div.appendChild(text); + } + + if (item.source) { + const source = document.createElement("p"); + source.className = "checkmark"; + source.textContent = `Source: ${item.source}`; + div.appendChild(source); + } + + container.appendChild(div); + }); + } + + function getSelectedResultLimit() { + const select = document.getElementById("result-limit"); + return parseInt(select.value, 10) || 10; // default to 10 if not set + } + + const handleSearchInput = debounce( + () => { + const query = document.getElementById("search-input").value.trim(); + console.log("Search query:", query); + if (query.length > 0) { + performSearch(); + } + }, + parseInt(SEARCH_OPTIONS.delay) || 300, + ); + + // Utility to get element by ID + const $ = (id) => document.getElementById(id); + + // Elements + const searchInput = $("search-input"); + const resultLimit = $("result-limit"); + + // Initialize search input if query param is present + const urlParams = new URLSearchParams(window.location.search); + const initialQuery = urlParams.get("q"); + if (initialQuery) { + searchInput.value = initialQuery; + } + + // Unified search trigger + const triggerSearch = () => { + const query = searchInput.value.trim(); + if (query) { + handleSearchInput(); + } + }; + + // Set up event listeners + searchInput.addEventListener("input", triggerSearch); + resultLimit?.addEventListener("change", performSearch); + SEARCH_BAR.addEventListener("input", (e) => { + searchInput.value = e.target.value; + triggerSearch(); + }); + + // Initialize search engine/data + initializeSearch(); + + // Optional: trigger on page load if query param was present + if (initialQuery) { + triggerSearch(); + } +}); diff --git a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/static/js/search.js b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/static/js/search.js index 059f253bd..02e44e097 100644 --- a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/static/js/search.js +++ b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/static/js/search.js @@ -94,11 +94,24 @@ require(["fuse"], function (Fuse) { resultItem.appendChild(resultText); fragment.appendChild(resultItem); + console.log("Adding search result item"); }); - + // Add Advanced Search Option + const advancedSearchItem = document.createElement("div"); + advancedSearchItem.className = "result-item advanced-search"; + advancedSearchItem.style.display = "flex"; + advancedSearchItem.style.justifyContent = "space-between"; + advancedSearchItem.style.alignItems = "center"; + const query = SEARCH_INPUT.value.trim(); + advancedSearchItem.dataset.href = ADVANCE_SEARCH_PATH + "?q=" + query; + advancedSearchItem.innerHTML = `Show all results Ctrl + Enter`; + advancedSearchItem.addEventListener("click", () => { + window.location.href = + ADVANCE_SEARCH_PATH + "?q=" + SEARCH_INPUT.value.trim(); + }); + fragment.appendChild(advancedSearchItem); RESULTS.appendChild(fragment); } - // Focus the selected result item function focusSelected(resultsItems) { if (CURRENT_INDEX >= 0 && CURRENT_INDEX < resultsItems.length) { @@ -188,13 +201,17 @@ require(["fuse"], function (Fuse) { event.preventDefault(); // Prevent default enter action const href = resultItems[CURRENT_INDEX].dataset.href; navigateToHref(href); - } - if (resultItems.length > 0) { + } else if (resultItems.length > 0) { event.preventDefault(); // Prevent default enter action const href = resultItems[0].dataset.href; navigateToHref(href); } - + // if cntrl + enter is pressed, navigate to the advanced search page + if (event.ctrlKey) { + event.preventDefault(); // Prevent default enter action + const query = SEARCH_INPUT.value.trim(); + window.location.href = ADVANCE_SEARCH_PATH + "?q=" + query; + } break; case "ArrowDown": diff --git a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/theme.conf b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/theme.conf index 5d06f558c..c00d088cf 100644 --- a/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/theme.conf +++ b/src/ansys_sphinx_theme/theme/ansys_sphinx_theme/theme.conf @@ -21,3 +21,5 @@ logo = static_search = whatsnew = use_ansys_search = True +search_extra_sources = +search_filters = diff --git a/tox.ini b/tox.ini index 3524367b7..b7e20501c 100644 --- a/tox.ini +++ b/tox.ini @@ -50,6 +50,7 @@ setenv = html: BUILDER = html pdf: BUILDER = latex links,html,pdf: BUILDER_OPTS = --color -v -j auto -W --keep-going + BUILD_EXAMPLES = commands = links,html,pdf: sphinx-build -d "{toxworkdir}/doc_doctree" {env:SOURCE_DIR} "{toxinidir}/{env:BUILD_DIR}/{env:BUILDER}" {env:BUILDER_OPTS} -b {env:BUILDER} clean: python -c "import shutil, sys; shutil.rmtree(sys.argv[1], ignore_errors=True)" "{toxinidir}/{env:BUILD_DIR}"