diff --git a/solr/cores/conf/schema.xml b/solr/cores/conf/schema.xml index c42b48d8..75fea3a0 100644 --- a/solr/cores/conf/schema.xml +++ b/solr/cores/conf/schema.xml @@ -79,7 +79,18 @@ - + + + + + + + + + + + + diff --git a/solr/cores/conf/solrconfig.xml b/solr/cores/conf/solrconfig.xml index 86d0a5e2..137d599c 100644 --- a/solr/cores/conf/solrconfig.xml +++ b/solr/cores/conf/solrconfig.xml @@ -84,6 +84,29 @@ text + + + + default + AnalyzingLookupFactory + DocumentDictionaryFactory + text + instrument_label_count_i + suggest_text_type + + + + + + true + 15 + default + + + suggest + + + text diff --git a/web-app/django/VIM/apps/instruments/management/commands/index_data.py b/web-app/django/VIM/apps/instruments/management/commands/index_data.py index 8ca2b99a..03ea8c36 100644 --- a/web-app/django/VIM/apps/instruments/management/commands/index_data.py +++ b/web-app/django/VIM/apps/instruments/management/commands/index_data.py @@ -66,6 +66,7 @@ def handle(self, *args, **options): hbs_code = instrument["hbs_prim_cat_s"] instrument["hbs_prim_cat_label_s"] = self.HBS_LABEL_MAP.get(hbs_code, "") + languages_with_name = set() for name_entry in instrument.pop("instrument_names_by_language", []): instrument_name_field = f"instrument_name_{name_entry['lang']}_ss" instrument_umil_label_field = ( @@ -77,6 +78,10 @@ def handle(self, *args, **options): instrument[instrument_name_field].append(name_entry["name"]) if name_entry.get("umil_label"): instrument[instrument_umil_label_field] = name_entry["name"] + if name_entry.get("name"): + languages_with_name.add(name_entry["lang"]) + + instrument["instrument_label_count_i"] = len(languages_with_name) # Initialize Solr client solr = pysolr.Solr(settings.SOLR_URL, timeout=10, always_commit=True) diff --git a/web-app/django/VIM/apps/instruments/views/solr_suggest.py b/web-app/django/VIM/apps/instruments/views/solr_suggest.py new file mode 100644 index 00000000..5f2f5873 --- /dev/null +++ b/web-app/django/VIM/apps/instruments/views/solr_suggest.py @@ -0,0 +1,59 @@ +# views.py +from django.http import JsonResponse +from django.views import View +import requests +from django.conf import settings + +SOLR_SUGGEST_URL = f"{settings.SOLR_URL}/suggest" + + +class SolrSuggest(View): + """ + Returns suggestions from Solr suggester directly. + """ + + def get(self, request): + query = request.GET.get("q", "").strip() + if not query: + return JsonResponse({"suggestions": []}) + solr_query = f"{query}" + + # Hit Solr suggest endpoint + try: + response = requests.get( + SOLR_SUGGEST_URL, params={"q": solr_query, "wt": "json"} + ) + response.raise_for_status() + except requests.RequestException: + return JsonResponse({"suggestions": []}, status=500) + + data = response.json() + suggestions = [] + + # Extract terms from Solr response and limit to top 5 case-insensitive + try: + suggest_data = data.get("suggest", {}).get("default", {}) + suggestions = [] + seen = set() + + if suggest_data: + first_key = list(suggest_data.keys())[0] + entries = suggest_data[first_key].get("suggestions", []) + + for e in entries: + term = e["term"] + term_lower = term.lower() + if term_lower not in seen: + seen.add(term_lower) + # Replace matched part with query string to preserve casing + if term_lower.startswith(query.lower()): + rest_part = term[len(query) :] + term = f"{query}{rest_part}" + + suggestions.append(term) + if len(suggestions) >= 5: + break + except Exception: + suggestions = [] + + return JsonResponse({"suggestions": suggestions}) diff --git a/web-app/django/VIM/templates/base.html b/web-app/django/VIM/templates/base.html index d48cc35d..940d4833 100644 --- a/web-app/django/VIM/templates/base.html +++ b/web-app/django/VIM/templates/base.html @@ -25,6 +25,7 @@ {% vite_asset 'src/main.ts' %} + {% vite_asset 'src/instruments/Suggest.ts' %} {% block ts_files %} {% endblock ts_files %} @@ -71,15 +72,20 @@ class="nav-link {% if active_tab == 'about' %}active{% endif %}">About -
diff --git a/web-app/django/VIM/urls.py b/web-app/django/VIM/urls.py index cb07e251..04e5c9cd 100644 --- a/web-app/django/VIM/urls.py +++ b/web-app/django/VIM/urls.py @@ -19,6 +19,7 @@ from django.urls import path, include from django.conf import settings from VIM.apps.instruments.views.instrument_list import InstrumentList +from VIM.apps.instruments.views.solr_suggest import SolrSuggest from VIM.apps.instruments.views.instrument_detail import InstrumentDetail from VIM.apps.instruments.views.update_umil_db import update_umil_db from VIM.apps.instruments.views.create_instrument import ( @@ -42,12 +43,13 @@ check_duplicate_names, name="api-check-duplicate-names", ), + path("instruments/suggest/", SolrSuggest.as_view(), name="solr-suggest"), path( - "instrument//", + "instrument//", InstrumentDetail.as_view(), name="instrument-detail", ), - path("instrument//names/", update_umil_db, name="update-umil-db"), + path("instrument//names/", update_umil_db, name="update-umil-db"), path( "api/instrument//delete/", delete_instrument, diff --git a/web-app/frontend/src/instruments/Suggest.ts b/web-app/frontend/src/instruments/Suggest.ts new file mode 100644 index 00000000..4807b083 --- /dev/null +++ b/web-app/frontend/src/instruments/Suggest.ts @@ -0,0 +1,89 @@ +async function fetchSuggestions(query: string): Promise { + if (!query.trim()) return []; + try { + const response = await fetch( + `/instruments/suggest/?q=${encodeURIComponent(query)}`, + ); + if (!response.ok) return []; + const data = await response.json(); + return Array.isArray(data.suggestions) ? data.suggestions : []; + } catch { + return []; + } +} + +function renderSuggestionList( + listElement: HTMLElement, + suggestions: string[], + input: HTMLInputElement, + form: HTMLFormElement, +) { + listElement.innerHTML = ''; + if (suggestions.length === 0) { + const empty = document.createElement('div'); + empty.className = 'list-group-item text-muted'; + empty.textContent = 'No suggestions'; + listElement.appendChild(empty); + return; + } + suggestions.forEach((suggestion) => { + const item = document.createElement('div'); + item.className = + 'list-group-item list-group-item-action notranslate force-ltr'; + item.innerHTML = suggestion; + // On click, set input value without tags + item.addEventListener('click', () => { + const temp = document.createElement('div'); + temp.innerHTML = suggestion; + input.value = temp.textContent || temp.innerText || ''; + form.submit(); + }); + listElement.appendChild(item); + }); +} + +window.addEventListener('DOMContentLoaded', () => { + const input = document.getElementById( + 'instrument-search', + ) as HTMLInputElement | null; + const list = document.getElementById( + 'autocomplete-list', + ) as HTMLElement | null; + + const form = input?.closest('form') as HTMLFormElement | null; + + if (!input || !list || !form) return; + + function showList() { + list.classList.remove('d-none'); + } + + function hideList() { + list.classList.add('d-none'); + } + + let debounceTimer: number | null = null; + + input.addEventListener('input', () => { + const val = input.value.trim(); + if (debounceTimer) { + window.clearTimeout(debounceTimer); + } + if (val.length > 1) { + debounceTimer = window.setTimeout(async () => { + const suggestions = await fetchSuggestions(val); + renderSuggestionList(list, suggestions, input, form); + showList(); + }, 150); + } else { + hideList(); + list.innerHTML = ''; + } + }); + + document.addEventListener('click', (event) => { + if (event.target !== input && !list.contains(event.target as Node)) { + hideList(); + } + }); +});