From 94e7f74906abc38f2bbdde93358784bd426ab1a1 Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Mon, 29 Dec 2025 16:49:58 -0500 Subject: [PATCH 1/9] feat: index number of labels per instrument as `instrument_label_count_i` --- .../VIM/apps/instruments/management/commands/index_data.py | 6 ++++++ 1 file changed, 6 insertions(+) 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..a65a3470 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,11 @@ 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) From b2a8b6c50aece889db501c672a1963c6991ab4b0 Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Mon, 22 Dec 2025 11:25:58 -0500 Subject: [PATCH 2/9] feat: enable suggestions with weighted document dictionary - endpoint at /suggest - use with `query*` confg --- solr/cores/conf/solrconfig.xml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/solr/cores/conf/solrconfig.xml b/solr/cores/conf/solrconfig.xml index 86d0a5e2..5b9882ee 100644 --- a/solr/cores/conf/solrconfig.xml +++ b/solr/cores/conf/solrconfig.xml @@ -84,6 +84,29 @@ text + + + + default + FuzzyLookupFactory + DocumentDictionaryFactory + text + instrument_label_count_i + string + + + + + + true + 5 + default + + + suggest + + + text From 7516cc2d02e96407beb84cc99a911e8c2000bf6d Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Wed, 24 Dec 2025 23:36:30 -0500 Subject: [PATCH 3/9] feat: add a static drop-down list used for search suggestions --- web-app/frontend/src/instruments/Suggest.ts | 65 +++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 web-app/frontend/src/instruments/Suggest.ts diff --git a/web-app/frontend/src/instruments/Suggest.ts b/web-app/frontend/src/instruments/Suggest.ts new file mode 100644 index 00000000..3c2f6984 --- /dev/null +++ b/web-app/frontend/src/instruments/Suggest.ts @@ -0,0 +1,65 @@ +// Static list of suggestions in the autocomplete-list div, + + +const staticSuggestions = ['Piano', 'Violin', 'Flute', 'Trumpet', 'Drums']; + +function renderSuggestionList( + listElement: HTMLElement, + suggestions: string[], + input: HTMLInputElement, + form: HTMLFormElement, +) { + listElement.innerHTML = ''; + suggestions.forEach((suggestion) => { + const item = document.createElement('div'); + item.className = 'list-group-item list-group-item-action'; + item.textContent = suggestion; + item.style.cursor = 'pointer'; + + // On click, set input value and submit the form + item.addEventListener('click', () => { + input.value = suggestion; + 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; + + renderSuggestionList(list, staticSuggestions, input, form); + + function showList() { + list.classList.remove('d-none'); + } + + function hideList() { + list.classList.add('d-none'); + } + + // Show suggestions when user types more than 2 chars + input.addEventListener('input', () => { + if (input.value.trim().length > 1) { + showList(); + } else { + hideList(); + } + }); + + // Hide when clicking outside + document.addEventListener('click', (event) => { + if (event.target !== input && !list.contains(event.target as Node)) { + hideList(); + } + }); +}); From 46d955d7794f09ba451118c288c979153046fa72 Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Thu, 25 Dec 2025 11:56:06 -0500 Subject: [PATCH 4/9] feat: query solr endpoint suggest to create the interactive list - create `SolrSuggest` for solr wildcard query - create suggest list in `.ts` by sending the query through the view --- .../apps/instruments/views/solr_suggest.py | 43 ++++++++++++++++++ web-app/django/VIM/templates/base.html | 10 ++++- web-app/django/VIM/urls.py | 6 ++- web-app/frontend/src/instruments/Suggest.ts | 44 ++++++++++++++----- 4 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 web-app/django/VIM/apps/instruments/views/solr_suggest.py 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..b0fea653 --- /dev/null +++ b/web-app/django/VIM/apps/instruments/views/solr_suggest.py @@ -0,0 +1,43 @@ +# 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 + try: + suggest_data = data.get("suggest", {}).get("default", {}) + if suggest_data: + first_key = list(suggest_data.keys())[0] + entries = suggest_data[first_key].get("suggestions", []) + suggestions = [e["term"] for e in entries][:5] + 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 index 3c2f6984..7f1fbcf7 100644 --- a/web-app/frontend/src/instruments/Suggest.ts +++ b/web-app/frontend/src/instruments/Suggest.ts @@ -1,7 +1,16 @@ -// Static list of suggestions in the autocomplete-list div, - - -const staticSuggestions = ['Piano', 'Violin', 'Flute', 'Trumpet', 'Drums']; +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.slice(0, 5) : []; + } catch { + return []; + } +} function renderSuggestionList( listElement: HTMLElement, @@ -10,11 +19,17 @@ function renderSuggestionList( 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'; item.textContent = suggestion; - item.style.cursor = 'pointer'; // On click, set input value and submit the form item.addEventListener('click', () => { @@ -37,8 +52,6 @@ window.addEventListener('DOMContentLoaded', () => { if (!input || !list || !form) return; - renderSuggestionList(list, staticSuggestions, input, form); - function showList() { list.classList.remove('d-none'); } @@ -47,16 +60,25 @@ window.addEventListener('DOMContentLoaded', () => { list.classList.add('d-none'); } - // Show suggestions when user types more than 2 chars + let debounceTimer: number | null = null; + input.addEventListener('input', () => { - if (input.value.trim().length > 1) { - showList(); + 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 = ''; } }); - // Hide when clicking outside document.addEventListener('click', (event) => { if (event.target !== input && !list.contains(event.target as Node)) { hideList(); From de39190ed966aa775ae5f473bdc46f142e4d0904 Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Thu, 25 Dec 2025 18:22:28 -0500 Subject: [PATCH 5/9] fix: make suggestions case-insensitive - introduce `suggest_text_type` as a dedicated field type because `text_general` contains NGram which can create loops in suggestions. - Solr does not store suggestions in a case-insensitive way, so query for the top 15 matches and filter case-insensitively in Django. - deduplicate suggestions and return up to 5 top results. --- solr/cores/conf/schema.xml | 13 ++++++++++++- solr/cores/conf/solrconfig.xml | 6 +++--- .../VIM/apps/instruments/views/solr_suggest.py | 11 ++++++++--- 3 files changed, 23 insertions(+), 7 deletions(-) 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 5b9882ee..137d599c 100644 --- a/solr/cores/conf/solrconfig.xml +++ b/solr/cores/conf/solrconfig.xml @@ -88,18 +88,18 @@ default - FuzzyLookupFactory + AnalyzingLookupFactory DocumentDictionaryFactory text instrument_label_count_i - string + suggest_text_type true - 5 + 15 default diff --git a/web-app/django/VIM/apps/instruments/views/solr_suggest.py b/web-app/django/VIM/apps/instruments/views/solr_suggest.py index b0fea653..6fab446a 100644 --- a/web-app/django/VIM/apps/instruments/views/solr_suggest.py +++ b/web-app/django/VIM/apps/instruments/views/solr_suggest.py @@ -16,7 +16,7 @@ def get(self, request): query = request.GET.get("q", "").strip() if not query: return JsonResponse({"suggestions": []}) - solr_query = f"{query}*" + solr_query = f"{query}" # Hit Solr suggest endpoint try: @@ -30,13 +30,18 @@ def get(self, request): data = response.json() suggestions = [] - # Extract terms from Solr response and limit to top 5 + # Extract terms from Solr response and limit to top 5 case-insensitive try: suggest_data = data.get("suggest", {}).get("default", {}) if suggest_data: first_key = list(suggest_data.keys())[0] entries = suggest_data[first_key].get("suggestions", []) - suggestions = [e["term"] for e in entries][:5] + seen = set() + suggestions = [ + e["term"] + for e in entries + if e["term"].lower() not in seen and not seen.add(e["term"].lower()) + ][:5] except Exception: suggestions = [] From af7daf02500cb7113e9de19e8c8ed8fd753d81c6 Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Thu, 25 Dec 2025 18:30:09 -0500 Subject: [PATCH 6/9] refactor: remove unnecessary frontend slicing of suggestions --- web-app/frontend/src/instruments/Suggest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-app/frontend/src/instruments/Suggest.ts b/web-app/frontend/src/instruments/Suggest.ts index 7f1fbcf7..38d87af4 100644 --- a/web-app/frontend/src/instruments/Suggest.ts +++ b/web-app/frontend/src/instruments/Suggest.ts @@ -6,7 +6,7 @@ async function fetchSuggestions(query: string): Promise { ); if (!response.ok) return []; const data = await response.json(); - return Array.isArray(data.suggestions) ? data.suggestions.slice(0, 5) : []; + return Array.isArray(data.suggestions) ? data.suggestions : []; } catch { return []; } From 9ef9c17cd276c5fa7e5cbc685d335bc0f5501eaa Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Thu, 25 Dec 2025 18:46:37 -0500 Subject: [PATCH 7/9] feat: highlight match in the suggestion bar --- .../apps/instruments/views/solr_suggest.py | 23 ++++++++++++++----- web-app/frontend/src/instruments/Suggest.ts | 9 ++++---- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/web-app/django/VIM/apps/instruments/views/solr_suggest.py b/web-app/django/VIM/apps/instruments/views/solr_suggest.py index 6fab446a..a906e4b8 100644 --- a/web-app/django/VIM/apps/instruments/views/solr_suggest.py +++ b/web-app/django/VIM/apps/instruments/views/solr_suggest.py @@ -33,15 +33,26 @@ def get(self, request): # 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", []) - seen = set() - suggestions = [ - e["term"] - for e in entries - if e["term"].lower() not in seen and not seen.add(e["term"].lower()) - ][:5] + + 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 = [] diff --git a/web-app/frontend/src/instruments/Suggest.ts b/web-app/frontend/src/instruments/Suggest.ts index 38d87af4..30d2f1de 100644 --- a/web-app/frontend/src/instruments/Suggest.ts +++ b/web-app/frontend/src/instruments/Suggest.ts @@ -29,11 +29,12 @@ function renderSuggestionList( suggestions.forEach((suggestion) => { const item = document.createElement('div'); item.className = 'list-group-item list-group-item-action'; - item.textContent = suggestion; - - // On click, set input value and submit the form + item.innerHTML = suggestion; + // On click, set input value without tags item.addEventListener('click', () => { - input.value = suggestion; + const temp = document.createElement('div'); + temp.innerHTML = suggestion; + input.value = temp.textContent || temp.innerText || ''; form.submit(); }); listElement.appendChild(item); From 07246e777d688bb7529c55a8d76accc52407a8df Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Thu, 25 Dec 2025 19:08:04 -0500 Subject: [PATCH 8/9] fix: add `notranslate` and `force-ltr` to the suggested fields --- web-app/frontend/src/instruments/Suggest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-app/frontend/src/instruments/Suggest.ts b/web-app/frontend/src/instruments/Suggest.ts index 30d2f1de..020fd7cc 100644 --- a/web-app/frontend/src/instruments/Suggest.ts +++ b/web-app/frontend/src/instruments/Suggest.ts @@ -28,7 +28,7 @@ function renderSuggestionList( } suggestions.forEach((suggestion) => { const item = document.createElement('div'); - item.className = 'list-group-item list-group-item-action'; + 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', () => { From 92d6382fb8a6cf7475621a27679525e809b5742c Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Mon, 29 Dec 2025 10:41:31 -0500 Subject: [PATCH 9/9] style: run code formatters --- .../VIM/apps/instruments/management/commands/index_data.py | 1 - web-app/django/VIM/apps/instruments/views/solr_suggest.py | 4 ++-- web-app/frontend/src/instruments/Suggest.ts | 7 ++++--- 3 files changed, 6 insertions(+), 6 deletions(-) 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 a65a3470..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 @@ -83,7 +83,6 @@ def handle(self, *args, **options): 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 index a906e4b8..5f2f5873 100644 --- a/web-app/django/VIM/apps/instruments/views/solr_suggest.py +++ b/web-app/django/VIM/apps/instruments/views/solr_suggest.py @@ -35,7 +35,7 @@ def get(self, request): 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", []) @@ -47,7 +47,7 @@ def get(self, request): seen.add(term_lower) # Replace matched part with query string to preserve casing if term_lower.startswith(query.lower()): - rest_part = term[len(query):] + rest_part = term[len(query) :] term = f"{query}{rest_part}" suggestions.append(term) diff --git a/web-app/frontend/src/instruments/Suggest.ts b/web-app/frontend/src/instruments/Suggest.ts index 020fd7cc..4807b083 100644 --- a/web-app/frontend/src/instruments/Suggest.ts +++ b/web-app/frontend/src/instruments/Suggest.ts @@ -2,7 +2,7 @@ async function fetchSuggestions(query: string): Promise { if (!query.trim()) return []; try { const response = await fetch( - `/instruments/suggest/?q=${encodeURIComponent(query)}` + `/instruments/suggest/?q=${encodeURIComponent(query)}`, ); if (!response.ok) return []; const data = await response.json(); @@ -28,7 +28,8 @@ function renderSuggestionList( } suggestions.forEach((suggestion) => { const item = document.createElement('div'); - item.className = 'list-group-item list-group-item-action notranslate force-ltr'; + 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', () => { @@ -48,7 +49,7 @@ window.addEventListener('DOMContentLoaded', () => { const list = document.getElementById( 'autocomplete-list', ) as HTMLElement | null; - + const form = input?.closest('form') as HTMLFormElement | null; if (!input || !list || !form) return;