Skip to content
Open
13 changes: 12 additions & 1 deletion solr/cores/conf/schema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,18 @@
<filter class="solr.LowerCaseFilterFactory" />
</analyzer>
</fieldType>

<!--Text type for AnalyzerFieldType in the suggester -->
<fieldType name="suggest_text_type" class="solr.TextField">
<analyzer type="index">
<tokenizer class="solr.StandardTokenizerFactory"/>
<filter class="solr.LowerCaseFilterFactory"/>
</analyzer>
<analyzer type="query">
<tokenizer class="solr.StandardTokenizerFactory"/>
<filter class="solr.LowerCaseFilterFactory"/>
</analyzer>
</fieldType>

<fieldtype name="ignored" stored="false" indexed="false" multiValued="true" class="solr.StrField" />

<!-- Define the new text field for queries -->
Expand Down
23 changes: 23 additions & 0 deletions solr/cores/conf/solrconfig.xml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,29 @@
<str name="df">text</str>
</lst>
</requestHandler>

<searchComponent name="suggest" class="solr.SuggestComponent">
<lst name="suggester">
<str name="name">default</str>
<str name="lookupImpl">AnalyzingLookupFactory</str>
<str name="dictionaryImpl">DocumentDictionaryFactory</str>
<str name="field">text</str>
<str name="weightField">instrument_label_count_i</str>
<str name="suggestAnalyzerFieldType">suggest_text_type</str>
</lst>
</searchComponent>

<requestHandler name="/suggest" class="solr.SearchHandler" startup="lazy">
<lst name="defaults">
<str name="suggest">true</str>
<str name="suggest.count">15</str>
<str name="suggest.dictionary">default</str>
</lst>
<arr name="components">
<str>suggest</str>
</arr>
</requestHandler>

<initParams path="/update/**,/query,/select,/tvrh,/elevate,/spell">
<lst name="defaults">
<str name="df">text</str>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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)
Expand Down
59 changes: 59 additions & 0 deletions web-app/django/VIM/apps/instruments/views/solr_suggest.py
Original file line number Diff line number Diff line change
@@ -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"<b>{query}</b>{rest_part}"

suggestions.append(term)
if len(suggestions) >= 5:
break
except Exception:
suggestions = []

return JsonResponse({"suggestions": suggestions})
10 changes: 8 additions & 2 deletions web-app/django/VIM/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<!-- End Google Tag Manager -->
<link rel="stylesheet" href="{% static 'assets/css/global.css' %}" />
{% vite_asset 'src/main.ts' %}
{% vite_asset 'src/instruments/Suggest.ts' %}
{% block ts_files %}
{% endblock ts_files %}

Expand Down Expand Up @@ -71,15 +72,20 @@
class="nav-link {% if active_tab == 'about' %}active{% endif %}">About</a>
</li>
</ul>
<form class="col-12 col-lg-auto mb-3 mb-lg-0 me-lg-3 force-rtl"
<form class="col-12 col-lg-auto mb-3 mb-lg-0 me-lg-3 force-rtl position-relative"
role="search"
action="{% url 'instrument-list' %}"
method="get">
<input type="search"
id="instrument-search"
name="query"
class="form-control search-input"
placeholder="Search..."
aria-label="Search" />
aria-label="Search"
autocomplete="off" />
<div id="autocomplete-list"
class="list-group position-absolute w-100 d-none bg-white small">
</div>
</form>
<!-- Right side content (user menu + translate) -->
<div class="d-flex align-items-center justify-content-center justify-content-lg-end">
Expand Down
6 changes: 4 additions & 2 deletions web-app/django/VIM/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -42,12 +43,13 @@
check_duplicate_names,
name="api-check-duplicate-names",
),
path("instruments/suggest/", SolrSuggest.as_view(), name="solr-suggest"),
path(
"instrument/<slug:umil_id>/",
"instrument/<int:pk>/",
InstrumentDetail.as_view(),
name="instrument-detail",
),
path("instrument/<slug:umil_id>/names/", update_umil_db, name="update-umil-db"),
path("instrument/<int:pk>/names/", update_umil_db, name="update-umil-db"),
path(
"api/instrument/<slug:umil_id>/delete/",
delete_instrument,
Expand Down
89 changes: 89 additions & 0 deletions web-app/frontend/src/instruments/Suggest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
async function fetchSuggestions(query: string): Promise<string[]> {
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 <b> 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();
}
});
});
Loading