PageSourceSearch

https://search.ugm.ac.id/ai/search&dsh/public/js/dsh-search.js?v=2026092415

js ugm.ac.id collected 2026-09-24 08:34:21 UTC 32,693 bytes, 783 lines download raw bytes

1/**
2 * dsh-search.js
3 * DSH Portal — Core search engine: term expansion, relevance scoring,
4 * filter application, fuzzy matching, search entry point.
5 *
6 * Sections:
7 *  1. Module state
8 *  2. expandTerms
9 *  3. calculateRelevanceScore
10 *  4. Fuzzy search (levenshtein + findFuzzyMatches)
11 *  5. performSearch — button handler
12 *  6. applyFilters — main orchestrator
13 *  7. Utility: buildTagOptions, buildOwnerOptions
14 */
15
16'use strict';
17
18// ---------------------------------------------------------------------------
19// 1. MODULE STATE
20// ---------------------------------------------------------------------------
21
22let allServices  = [];
23let currentTerms = [];
24
25// Scraped-results cache (populated by dsh-data.js)
26const scrapedResultsCache    = new Map();
27const activeScrapingRequests = new Map();
28
29// ---------------------------------------------------------------------------
30// 2. EXPAND TERMS (synonym + n-gram expansion)
31// ---------------------------------------------------------------------------
32
33function expandTerms(q) {
34  // Remove stopwords from query
35  const base = normalizeText(q)
36    .split(/\s+/)
37    .filter(Boolean)
38    .filter(term => !STOPWORDS.has(term));
39
40  if (base.length === 0) return [];
41
42  const expanded = new Set(base);
43
44  // Multi-word: preserve full phrase + bi-grams + tri-grams
45  if (base.length > 1) {
46    expanded.add(base.join(' '));
47    for (let i = 0; i < base.length - 1; i++) {
48      expanded.add(base[i] + ' ' + base[i + 1]);
49    }
50    if (base.length >= 3) {
51      for (let i = 0; i < base.length - 2; i++) {
52        expanded.add(base[i] + ' ' + base[i + 1] + ' ' + base[i + 2]);
53      }
54    }
55  }
56
57  // Exact synonym matches
58  base.forEach(t => {
59    if (SYNS[t]) SYNS[t].forEach(x => expanded.add(normalizeText(x)));
60  });
61
62  // Partial synonym expansion (conservative for multi-word)
63  Object.entries(SYNS).forEach(([k, arr]) => {
64    if (base.some(t => k.includes(t))) {
65      if (base.length === 1 || k === base.join(' ')) {
66        arr.forEach(x => expanded.add(normalizeText(x)));
67      }
68    }
69    arr.forEach(syn => {
70      if (base.some(t => normalizeText(syn).includes(t))) {
71        expanded.add(normalizeText(syn));
72        if (base.length === 1) arr.forEach(x => expanded.add(normalizeText(x)));
73      }
74    });
75  });
76
77  return Array.from(expanded);
78}
79
80// ---------------------------------------------------------------------------
81// 3. RELEVANCE SCORING
82// ---------------------------------------------------------------------------
83
84function calculateRelevanceScore(service, searchTerms, originalQuery) {
85  let score = 0;
86  const normalizedQuery = normalizeText(originalQuery);
87  const weights     = SEARCH_CONFIG.WEIGHTS;
88  const multipliers = SEARCH_CONFIG.SCORE_MULTIPLIERS;
89
90  const queryWords = normalizedQuery.split(/\s+/).filter(w => !STOPWORDS.has(w));
91  const isMultiWord = queryWords.length > 1;
92
93  Object.entries(weights).forEach(([field, weight]) => {
94    const fieldName  = field.toLowerCase();
95    const origValue  = service[fieldName] || '';
96    const fieldValue = normalizeText(origValue);
97    if (!fieldValue) return;
98
99    // Tier 1 — exact phrase with word boundaries
100    if (hasExactPhrase(origValue, originalQuery)) {
101      score += weight * multipliers.EXACT_PHRASE;
102      if (/^/.test(fieldValue) && fieldValue.startsWith(normalizedQuery)) {
103        score += weight * 2;
104      }
105    }
106
107    // Tier 2 — all query words present (multi-word)
108    if (queryWords.length > 0) {
109      const wordsWithBoundaries = queryWords.filter(w => hasExactWord(origValue, w));
110      if (wordsWithBoundaries.length === queryWords.length && queryWords.length > 1) {
111        score += weight * multipliers.PARTIAL_PHRASE;
112        for (let i = 0; i < queryWords.length - 1; i++) {
113          if (hasExactPhrase(origValue, queryWords[i] + ' ' + queryWords[i + 1])) {
114            score += weight * multipliers.INDIVIDUAL_TERM * 1.5;
115          }
116        }
117        if (queryWords.length >= 3) {
118          for (let i = 0; i < queryWords.length - 2; i++) {
119            const trigram = queryWords[i] + ' ' + queryWords[i + 1] + ' ' + queryWords[i + 2];
120            if (hasExactPhrase(origValue, trigram)) score += weight * multipliers.PARTIAL_PHRASE * 0.8;
121          }
122        }
123      }
124    }
125
126    // Tier 3 — individual term matches
127    let termHits = 0, wordStarts = 0;
128    searchTerms.forEach(term => {
129      if (hasExactWord(origValue, term)) {
130        termHits++;
131        if (fieldValue.startsWith(normalizeText(term))) wordStarts++;
132      }
133    });
134    if (searchTerms.length > 0) {
135      score += weight * (termHits / searchTerms.length) * multipliers.INDIVIDUAL_TERM;
136      if (wordStarts > 0) score += weight * (wordStarts / searchTerms.length) * 0.5;
137    }
138  });
139
140  // Tier 4 — multi-field bonus
141  const fieldsWithHits = Object.keys(weights).filter(f =>
142    searchTerms.some(t => hasExactWord(service[f.toLowerCase()] || '', t))
143  );
144  if (fieldsWithHits.length > 2) score += multipliers.MULTI_FIELD_BONUS;
145
146  // Tier 5 — all query words found across all fields
147  if (queryWords.length > 1) {
148    const allFieldText = Object.keys(weights).map(f => service[f.toLowerCase()] || '').join(' ');
149    if (queryWords.every(w => hasExactWord(allFieldText, w))) {
150      score += multipliers.MULTI_FIELD_BONUS * 0.5;
151    }
152  }
153
154  return score;
155}
156
157// ---------------------------------------------------------------------------
158// 4. FUZZY SEARCH
159// ---------------------------------------------------------------------------
160
161function levenshteinDistance(str1, str2) {
162  const len1 = str1.length, len2 = str2.length;
163  const matrix = Array.from({ length: len1 + 1 }, (_, i) => {
164    const row = new Array(len2 + 1).fill(0);
165    row[0] = i;
166    return row;
167  });
168  for (let j = 0; j <= len2; j++) matrix[0][j] = j;
169
170  for (let i = 1; i <= len1; i++) {
171    for (let j = 1; j <= len2; j++) {
172      const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
173      matrix[i][j] = Math.min(
174        matrix[i - 1][j] + 1,
175        matrix[i][j - 1] + 1,
176        matrix[i - 1][j - 1] + cost,
177      );
178    }
179  }
180  return matrix[len1][len2];
181}
182
183function findFuzzyMatches(query, maxDistance = 2, limit = 3) {
184  if (!query || query.length < 3) return [];
185  const normQ       = normalizeText(query);
186  const suggestions = new Map();
187
188  allServices.forEach(svc => {
189    const text  = [svc.name, svc.description, svc.tag, svc.owner, svc.audience].filter(Boolean).join(' ');
190    const words = normalizeText(text).split(/\s+/);
191    words.forEach(word => {
192      if (word.length >= 3) {
193        const dist = levenshteinDistance(normQ, word);
194        if (dist > 0 && dist <= maxDistance) {
195          const origText  = [svc.name, svc.description, svc.tag].filter(Boolean).join(' ');
196          const regex     = new RegExp(`\\b${word}\\w*\\b`, 'i');
197          const match     = origText.match(regex);
198          if (match && !suggestions.has(match[0].toLowerCase())) {
199            suggestions.set(match[0].toLowerCase(), { term: match[0], distance: dist, score: 1 / (dist + 1) });
200          }
201        }
202      }
203    });
204  });
205
206  return Array.from(suggestions.values())
207    .sort((a, b) => a.distance - b.distance || b.score - a.score)
208    .slice(0, limit)
209    .map(item => item.term);
210}
211
212// ---------------------------------------------------------------------------
213// 5. PERFORM SEARCH (button handler)
214// ---------------------------------------------------------------------------
215
216async function performSearch() {
217  const searchBtn     = document.getElementById('searchBtn');
218  const searchIcon    = document.getElementById('searchIcon');
219  const searchSpinner = document.getElementById('searchSpinner');
220
221  // Hapus animasi pulse yang muncul saat user mengetik
222  searchBtn?.classList.remove('animate-pulse');
223
224  try {
225    if (searchBtn) searchBtn.disabled = true;
226    if (searchIcon) searchIcon.style.visibility = 'hidden';
227    searchSpinner?.classList.remove('hidden');
228    await applyFilters();
229  } catch (err) {
230    console.error('[performSearch]', err);
231    showError('Terjadi kesalahan saat mencari. Silakan coba lagi.');
232  } finally {
233    if (searchBtn) searchBtn.disabled = false;
234    if (searchIcon) searchIcon.style.visibility = '';
235    searchSpinner?.classList.add('hidden');
236  }
237}
238
239/**
240 * Kueri ditolak guardrail server (konten eksplisit / promosi judi): tampilkan
241 * pesan kebijakan tanpa hasil lokal, CTA pencarian eksternal, maupun panel AI.
242 */
243function renderGuardrailNotice(guard, qraw) {
244  const servicesGrid = document.getElementById('servicesGrid');
245  const emptyState   = document.getElementById('emptyState');
246  if (servicesGrid) servicesGrid.innerHTML = '';
247  const title = emptyState?.querySelector('h3');
248  const desc  = emptyState?.querySelector('p');
249  if (title) title.textContent = 'Pencarian tidak dapat diproses';
250  if (desc)  desc.textContent  = guard.message || '';
251  emptyState?.classList.remove('hidden');
252
253  hideNoResultsCTA();
254  if (typeof hideRAGPanel === 'function') hideRAGPanel();
255  if (typeof hideAITypingHint === 'function') hideAITypingHint();
256  setResultFilterVisible(false);
257  updateTabCounts([]);
258  updateSearchResultInfo(0, qraw);
259  hideFuzzySuggestions();
260  hideSearchHistory();
261  setTimeout(() => hideLoading(), 300);
262}
263
264// ---------------------------------------------------------------------------
265// 6. APPLY FILTERS (main search orchestrator)
266// ---------------------------------------------------------------------------
267
268async function applyFilters() {
269  const searchInput = document.getElementById('searchInput');
270  const filterDharma = document.getElementById('filterDharma');
271  const activeFiltersEl = document.getElementById('activeFilters');
272
273  const qraw      = (searchInput?.value || '').trim();
274  const q         = normalizeText(qraw);
275  const dh        = filterDharma?.value || 'all';
276  const tagSel    = document.getElementById('filterTag')?.value       || 'all';
277  const sort      = document.getElementById('sortMode')?.value        || 'admin';
278  const ownerSel  = document.getElementById('filterOwner')?.value     || 'all';
279  const audienceSel = document.getElementById('filterAudience')?.value || 'all';
280
281  const hasQuery = qraw.length > 0;
282  if (hasQuery) {
283    showLoading();
284    document.dispatchEvent(new CustomEvent('dsh:searchStart', { detail: { q: qraw } }));
285  } else {
286    document.dispatchEvent(new CustomEvent('dsh:searchClear'));
287  }
288
289  // Reset card keyword search saat query baru
290  const cardSearchInput = document.getElementById('cardSearchInput');
291  if (cardSearchInput && cardSearchInput.value) {
292    cardSearchInput.value = '';
293    filterCardsByKeyword('');
294  }
295
296  // ---- UI: show/hide section ----
297  const layananSection = document.getElementById('layanan');
298  document.getElementById('filtersPanelContainer')?.classList.add('hidden');
299
300  if (qraw.length > 0) {
301    if (layananSection?.classList.contains('hidden')) {
302      layananSection.classList.remove('hidden');
303      setTimeout(() => layananSection.scrollIntoView({ behavior: 'smooth', block: 'start' }), 200);
304    }
305  } else if (dh === 'all' && tagSel === 'all' && ownerSel === 'all' && audienceSel === 'all') {
306    layananSection?.classList.add('hidden');
307  } else {
308    if (layananSection?.classList.contains('hidden')) {
309      layananSection.classList.remove('hidden');
310      setTimeout(() => layananSection.scrollIntoView({ behavior: 'smooth', block: 'start' }), 200);
311    }
312  }
313
314  writeParams({ q: qraw, dh, tag: tagSel, sort });
315
316  // ---- Active filter chips ----
317  if (activeFiltersEl) {
318    activeFiltersEl.innerHTML = '';
319    if (dh       !== 'all') activeFiltersEl.innerHTML += `<span class="text-xs px-2 py-1 rounded-md border border-blue-300 bg-blue-50 text-blue-800 font-medium">📚 ${escapeHtml(dh)}</span>`;
320    if (tagSel   !== 'all') activeFiltersEl.innerHTML += `<span class="text-xs px-2 py-1 rounded-md border border-purple-300 bg-purple-50 text-purple-800 font-medium">🏷️ ${escapeHtml(tagSel)}</span>`;
321    if (ownerSel !== 'all') activeFiltersEl.innerHTML += `<span class="text-xs px-2 py-1 rounded-md border border-green-300 bg-green-50 text-green-800 font-medium">🏛️ ${escapeHtml(ownerSel)}</span>`;
322    if (audienceSel !== 'all') activeFiltersEl.innerHTML += `<span class="text-xs px-2 py-1 rounded-md border border-cyan-300 bg-cyan-50 text-cyan-800 font-medium">👥 ${escapeHtml(audienceSel)}</span>`;
323    if (qraw) {
324      const btn = document.createElement('button');
325      btn.className = 'text-xs px-2 py-1 rounded-md border border-red-300 bg-red-50 text-red-800 font-medium hover:bg-red-100 transition-colors';
326      btn.onclick = () => { document.getElementById('searchInput').value = ''; applyFilters(); };
327      btn.textContent = `🔍 "${qraw}" ✕`;
328      activeFiltersEl.appendChild(btn);
329    }
330  }
331
332  // ---- Expand terms ----
333  const terms = q ? expandTerms(q) : [];
334  currentTerms = terms.filter(t => t.length >= 2);
335
336  const isMultiWordQuery = qraw.split(/\s+/).filter(Boolean).length > 1;
337
338  // ---- Scraping step ----
339  let scrapedServices = [];
340  if (qraw && qraw.length >= 3) {
341    markStepActive(1);
342    updateLoadingProgress(20, 'Mencari layanan dan informasi terkait...');
343    scrapedServices = await loadScrapedServices(qraw, 20);
344    markStepComplete(1);
345    if (window.dshGuardrail?.query === qraw) {
346      renderGuardrailNotice(window.dshGuardrail, qraw);
347      return;
348    }
349  } else if (hasQuery) {
350    markStepActive(1);
351    updateLoadingProgress(50, 'Mencari layanan...');
352    setTimeout(() => markStepComplete(1), 300);
353  }
354
355  const hasActiveSubFilter = Object.values(activeSubFilters).some(v => v !== '');
356
357  let combinedServices;
358  if (hasActiveSubFilter) {
359    combinedServices = scrapedServices;
360  } else {
361    combinedServices = mergeResults(allServices, scrapedServices);
362  }
363
364  // ---------------------------------------------------------------------------
365  // passSubFilter — per entity type sub-filter matching.
366  //
367  // Each entity type has its own filterable attributes (unique per card type).
368  // A filter for one type DOES NOT exclude items of other types.
369  //
370  // Usage example (called in the filter loop below):
371  //   filtered = combined.filter(s => passD && passT && passA && passSubFilter(s));
372  //
373  // Supported attributes per entity:
374  //   news       → activeSubFilters.context (news_context), .location (news_location)
375  //   people     → activeSubFilters.department (department / faculty_name)
376  //   service    → activeSubFilters.dharma
377  //   product    → activeSubFilters.productSource (source_origin)
378  //   publication → activeSubFilters.publicationYear (year), .publicationGok (faculty_name)
379  //   patent     → activeSubFilters.patentType (ip_type)
380  //   legal      → activeSubFilters.legalDocType (extra_data.doc_type)
381  //   pidato     → activeSubFilters.pidatoJenis  (extra_data.jenis)
382  // ---------------------------------------------------------------------------
383  const passSubFilter = s => {
384    const af = activeSubFilters;
385    // News-only filters: context and location only apply to news, NOT to events
386    if (s.entity_type === 'news') {
387      if (af.context  && s.news_context  !== af.context)  return false;
388      if (af.location && s.news_location !== af.location) return false;
389    }
390    if (s.entity_type === 'people') {
391      if (af.department) {
392        const d = s.department || s.faculty_name || '';
393        if (d !== af.department && !d.includes(af.department)) return false;
394      }
395    }
396    if (s.entity_type === 'service') {
397      if (af.dharma && s.dharma !== af.dharma) return false;
398    }
399    if (s.entity_type === 'product') {
400      if (af.productSource && s.source_origin !== af.productSource) return false;
401    }
402    if (s.entity_type === 'publication') {
403      if (af.publicationYear && String(s.year) !== String(af.publicationYear)) return false;
404      if (af.publicationGok) {
405        const fl = s.faculty_name || s.faculty || '';
406        if (fl !== af.publicationGok && !fl.includes(af.publicationGok)) return false;
407      }
408    }
409    if (s.entity_type === 'patent') {
410      if (af.patentType && s.ip_type !== af.patentType) return false;
411    }
412    if (s.entity_type === 'legal') {
413      if (af.legalDocType) {
414        const extra = (() => { try { return JSON.parse(s.extra_data || '{}'); } catch { return {}; } })();
415        const dt = extra.doc_type || s.doc_type || '';
416        if (dt !== af.legalDocType) return false;
417      }
418    }
419    if (s.entity_type === 'pidato') {
420      if (af.pidatoJenis) {
421        const extra = (() => { try { return JSON.parse(s.extra_data || '{}'); } catch { return {}; } })();
422        const jenis = extra.jenis || s.jenis || '';
423        if (jenis !== af.pidatoJenis) return false;
424      }
425    }
426    if (s.entity_type === 'facility') {
427      if (af.facilityCategory && s.category !== af.facilityCategory) return false;
428    }
429    // Karir filters: category, location, deadline status
430    if (s.entity_type === 'karir') {
431      const extra = (() => { try { return JSON.parse(s.extra_data || '{}'); } catch { return {}; } })();
432      const category = extra.category || s.category || '';
433      const location = extra.location || s.location || '';
434      const deadline = extra.deadline || s.deadline || '';
435      
436      // Category filter
437      if (af.karirCategory && category !== af.karirCategory) return false;
438      
439      // Location filter
440      if (af.karirLocation && location !== af.karirLocation) return false;
441      
442      // Deadline filter
443      if (af.karirDeadline && deadline) {
444        const deadlineDate = new Date(deadline);
445        const now = new Date();
446        const diffTime = deadlineDate - now;
447        const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
448        
449        switch (af.karirDeadline) {
450          case 'active':   // Masih aktif (deadline > hari ini)
451            if (diffDays < 0) return false;
452            break;
453          case 'urgent':   // Mendesak (≤7 hari)
454            if (diffDays < 0 || diffDays > 7) return false;
455            break;
456          case 'thisMonth': // Bulan ini
457            const deadlineMonth = deadlineDate.getMonth();
458            const deadlineYear = deadlineDate.getFullYear();
459            const nowMonth = now.getMonth();
460            const nowYear = now.getFullYear();
461            if (deadlineMonth !== nowMonth || deadlineYear !== nowYear) return false;
462            break;
463          case 'expired':  // Sudah lewat
464            if (diffDays >= 0) return false;
465            break;
466        }
467      }
468    }
469
470    // ── Filter lanjutan tambahan ─────────────────────────────────────────────
471    // Masing-masing hanya berlaku untuk tipe kontennya sendiri, sehingga item
472    // tipe lain tidak ikut tersaring saat tab "Semua" aktif.
473    const ekstra = (() => { try { return JSON.parse(s.extra_data || '{}'); } catch { return {}; } })();
474    const ambil = (k) => s[k] ?? ekstra[k] ?? '';
475
476    if (s.entity_type === 'video') {
477      if (af.videoType && ambil('content_type') && ambil('content_type') !== af.videoType) return false;
478      if (af.videoYear && String(ambil('upload_date')).slice(0, 4) !== af.videoYear) return false;
479      if (af.videoDuration) {
480        const detik = Number(ambil('duration_seconds')) || 0;
481        const rentang = {
482          lt1:    [1, 59],
483          '1to5': [60, 299],
484          '5to15':[300, 899],
485          '15to30':[900, 1799],
486          gt30:   [1800, Infinity],
487        }[af.videoDuration];
488        if (rentang && (detik < rentang[0] || detik > rentang[1])) return false;
489      }
490    }
491    if (s.entity_type === 'news') {
492      if (af.newsSourceType && ambil('source_type') && ambil('source_type') !== af.newsSourceType) return false;
493      if (af.newsSource && ambil('source_name') && ambil('source_name') !== af.newsSource) return false;
494    }
495    if (s.entity_type === 'publication' && af.publicationType) {
496      if (ambil('publication_type') && ambil('publication_type') !== af.publicationType) return false;
497    }
498    if (s.entity_type === 'product' && af.productType) {
499      if (ambil('product_type') && ambil('product_type') !== af.productType) return false;
500    }
501    if (s.entity_type === 'legal' && af.legalYear) {
502      if (String(ambil('year')) !== af.legalYear) return false;
503    }
504    if (s.entity_type === 'patent' && af.patentYear) {
505      if (String(ambil('year')) !== af.patentYear) return false;
506    }
507
508    return true;
509  };
510
511  // ---- Filter ----
512  const filtered = combinedServices.filter(s => {
513    const passD = dh === 'all' || s.dharma === dh;
514
515    let passT = true;
516    if (tagSel !== 'all') {
517      const tags = (s.tag || '').split(',').map(t => t.trim().toLowerCase());
518      passT = tags.includes(tagSel.toLowerCase());
519    }
520
521    const passO = ownerSel === 'all' || (s.owner || '') === ownerSel;
522
523    let passA = true;
524    if (audienceSel !== 'all') {
525      const aud = (s.audience || '').split(',').map(a => a.trim());
526      passA = aud.includes(audienceSel);
527    }
528
529    if (!q) return passD && passT && passO && passA && passSubFilter(s);
530
531    const hay = normalizeText(
532      [s.name, s.description, s.tag, s.dharma, s.owner, s.audience, s.icon, s.url]
533        .filter(Boolean).join(' ')
534    );
535
536    if (isMultiWordQuery) {
537      const score = calculateRelevanceScore(s, currentTerms, q);
538      s._searchScore = score;
539      return passD && passT && passO && passA && score > 0 && passSubFilter(s);
540    } else {
541      const hit = currentTerms.some(t => hay.includes(t));
542      s._searchScore = hit ? 1 : 0;
543      return passD && passT && passO && passA && hit && passSubFilter(s);
544    }
545  });
546
547  // ---- Sort ----
548  let sorted = filtered.slice();
549  if (q && isMultiWordQuery) {
550    sorted.sort((a, b) => (b._searchScore || 0) - (a._searchScore || 0));
551  } else if (sort === 'az') {
552    sorted.sort((a, b) => a.name.localeCompare(b.name, 'id'));
553  } else if (sort === 'newest') {
554    sorted.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
555  }
556
557  // ---- Cache ----
558  if (qraw.length > 0) {
559    cacheSearchResults(qraw, sorted);
560  } else {
561    invalidateSearchCache();
562  }
563
564  // ---- Panel filter hasil: tampil bersama hasil pencarian ----
565  // Sidebar kiri ikut dibuka di sini, sama seperti pada mode telusur, supaya
566  // filter tersedia langsung tanpa pengguna perlu mencarinya.
567  setResultFilterVisible(qraw.length > 0);
568  updateTabCounts(sorted);
569  // Refresh sub-filter option counts based on live results
570  if (typeof updateSubFilterCounts === 'function') updateSubFilterCounts(sorted);
571  // Show/hide the correct sub-filter panel for the active tab
572  if (typeof updateSubFiltersVisibility === 'function') updateSubFiltersVisibility(currentResultTab);
573
574  // ---- Tab filter ----
575  // Peta tunggal ada di dsh-filters.js (TAB_FILTERS). Salinan lokal yang dulu di sini
576  // tidak punya videos/karir/events, sehingga ketiga tab itu menampilkan seluruh hasil.
577  const tabFilterMap = window.DSH_TAB_FILTERS || {};
578  const displayItems = tabFilterMap[currentResultTab]
579    ? sorted.filter(tabFilterMap[currentResultTab])
580    : sorted;
581
582  // ---- Render ----
583  if (hasQuery) {
584    markStepActive(3);
585    updateLoadingProgress(85, 'Menampilkan hasil pencarian...');
586  }
587
588  renderCards(displayItems);
589  updateQuickStats(filtered);
590  updateSearchResultInfo(displayItems.length, qraw);
591
592  if (hasQuery) setTimeout(() => hideLoading(), 500);
593
594  // ---- History + fuzzy ----
595  if (qraw.length >= 2) saveSearchHistory(qraw);
596  if (qraw.length >= 3 && filtered.length === 0) {
597    // Saran server (kosakata seluruh korpus) didahulukan, lalu kecocokan nama layanan
598    const serverTip = window.dshDidYouMean?.query === qraw ? [window.dshDidYouMean.suggestion] : [];
599    showFuzzySuggestions([...new Set([...serverTip, ...findFuzzyMatches(qraw, 2, 3)])].slice(0, 3));
600  } else {
601    hideFuzzySuggestions();
602  }
603  hideSearchHistory();
604}
605
606// ---------------------------------------------------------------------------
607// TAB SWITCHING — lihat switchResultTab() di dsh-filters.js.
608// Berkas ini dimuat sesudah dsh-filters.js, jadi salinan yang dulu ada di sini
609// menimpa versi aslinya dan membuat tab Fasilitas & Karir tidak berfungsi.
610// ---------------------------------------------------------------------------
611
612/**
613 * filterCardsByKeyword — filter atau search card berdasarkan keyword
614 * - Jika dalam browse mode: trigger pencarian ke API untuk semua halaman
615 * - Jika dalam search mode: filter card yang sudah tampil di grid (client-side)
616 * Dipanggil real-time dari input #cardSearchInput di sidebar.
617 * @param {string} kw — keyword pencarian (case-insensitive)
618 */
619let cardSearchTimeout = null;
620async function filterCardsByKeyword(kw) {
621  const grid = document.getElementById('servicesGrid');
622  if (!grid) return;
623
624  const keyword = (kw || '').trim().toLowerCase();
625  
626  // Jika dalam browse mode dan ada keyword, lakukan server-side search
627  if (isBrowseMode && keyword.length >= 2) {
628    // Debounce untuk menghindari terlalu banyak request
629    clearTimeout(cardSearchTimeout);
630    cardSearchTimeout = setTimeout(async () => {
631      const token = typeof nextBrowseRequest === 'function' ? nextBrowseRequest() : null;
632      try {
633        // Tampilkan loading indicator
634        const loadingMsg = document.createElement('div');
635        loadingMsg.id = 'cardSearchLoading';
636        loadingMsg.className = 'col-span-full text-center py-8 text-blue-500 text-sm';
637        loadingMsg.innerHTML = `<div class="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2"></div><div>Mencari "${escapeHtml(kw)}" di semua halaman...</div>`;
638        grid.innerHTML = '';
639        grid.appendChild(loadingMsg);
640        
641        // Build search URL dengan filter yang aktif
642        let url = `${DATA_API_BASE}?action=browse&type=${currentBrowseType}&limit=100&page=1&q=${encodeURIComponent(keyword)}`;
643        
644        // Tambahkan sub-filters yang aktif
645        if (typeof activeSubFilters !== 'undefined') {
646          if (activeSubFilters.context)         url += `&context=${encodeURIComponent(activeSubFilters.context)}`;
647          if (activeSubFilters.location)        url += `&location=${encodeURIComponent(activeSubFilters.location)}`;
648          if (activeSubFilters.department)      url += `&department=${encodeURIComponent(activeSubFilters.department)}`;
649          if (activeSubFilters.dharma)          url += `&dharma=${encodeURIComponent(activeSubFilters.dharma)}`;
650          if (activeSubFilters.productSource)   url += `&product_source=${encodeURIComponent(activeSubFilters.productSource)}`;
651          if (activeSubFilters.publicationYear) url += `&publication_year=${encodeURIComponent(activeSubFilters.publicationYear)}`;
652          if (activeSubFilters.publicationGok)  url += `&publication_gok=${encodeURIComponent(activeSubFilters.publicationGok)}`;
653          if (activeSubFilters.patentType)      url += `&patent_type=${encodeURIComponent(activeSubFilters.patentType)}`;
654          if (activeSubFilters.legalDocType)    url += `&legal_doc_type=${encodeURIComponent(activeSubFilters.legalDocType)}`;
655          if (activeSubFilters.pidatoJenis)     url += `&pidato_jenis=${encodeURIComponent(activeSubFilters.pidatoJenis)}`;
656          if (activeSubFilters.facilityCategory) url += `&facility_category=${encodeURIComponent(activeSubFilters.facilityCategory)}`;
657          if (activeSubFilters.eventDateFrom)   url += `&event_date_from=${encodeURIComponent(activeSubFilters.eventDateFrom)}`;
658          if (activeSubFilters.eventDateTo)     url += `&event_date_to=${encodeURIComponent(activeSubFilters.eventDateTo)}`;
659          // Filter lanjutan tambahan ikut dibawa agar pencarian dalam hasil
660          // tetap menghormati filter yang sedang aktif.
661          for (const f of (window.EXTRA_SUBFILTERS || [])) {
662            const nilai = activeSubFilters[f.key];
663            if (nilai) url += `&${f.param}=${encodeURIComponent(nilai)}`;
664          }
665        }
666        
667        const res = await fetch(url);
668        const json = await res.json();
669        
670        if (!json.ok) throw new Error(json.error || 'Gagal mencari');
671        // Pengguna mungkin sudah pindah kategori sementara permintaan ini berjalan
672        if (token !== null && !isLatestBrowseRequest(token)) return;
673
674        const results = json.data.results || [];
675        const total = json.data.total || 0;
676        
677        // Render hasil pencarian
678        if (typeof renderCards === 'function') {
679          renderCards(results);
680        }
681        
682        // Update info
683        const infoEl = document.getElementById('searchResultInfo');
684        if (infoEl) {
685          infoEl.innerHTML = `🔍 Ditemukan <strong>${total.toLocaleString('id-ID')}</strong> hasil untuk "${escapeHtml(kw)}" di ${getEntityLabel(currentBrowseType)}`;
686          infoEl.classList.remove('hidden');
687        }
688        
689        // Hide pagination saat search
690        const paginationContainer = document.getElementById('paginationContainer');
691        if (paginationContainer) {
692          paginationContainer.style.display = 'none';
693        }
694        
695      } catch (err) {
696        if (token !== null && !isLatestBrowseRequest(token)) return;
697        console.error('[filterCardsByKeyword] Browse search error:', err);
698        grid.innerHTML = `<div class="col-span-full text-center py-8 text-red-500 text-sm">⚠️ Gagal mencari: ${err.message}</div>`;
699      }
700    }, 500); // 500ms debounce
701    return;
702  }
703  
704  // Jika keyword kosong dalam browse mode, reload data browse normal
705  if (isBrowseMode && !keyword) {
706    clearTimeout(cardSearchTimeout);
707    if (typeof executeBrowse === 'function') {
708      executeBrowse(currentBrowseType, currentBrowsePage);
709    }
710    return;
711  }
712  
713  // Client-side filtering untuk non-browse mode (atau browse tanpa keyword)
714  let visibleCount = 0;
715
716  grid.querySelectorAll('.svc-card').forEach(card => {
717    if (!keyword) {
718      card.style.display = '';
719      visibleCount++;
720      return;
721    }
722    // Ambil semua teks yang terlihat di dalam card
723    const text = (card.textContent || '').toLowerCase();
724    const match = text.includes(keyword);
725    card.style.display = match ? '' : 'none';
726    if (match) visibleCount++;
727  });
728
729  // Tampilkan/sembunyikan divider RAG jika semua card di sisinya tersembunyi
730  grid.querySelectorAll('.rag-relevance-divider').forEach(d => {
731    const prev = d.previousElementSibling;
732    const next = d.nextElementSibling;
733    const prevHidden = !prev || prev.style.display === 'none';
734    const nextHidden = !next || next.style.display === 'none';
735    d.style.display = (prevHidden && nextHidden) ? 'none' : '';
736  });
737
738  // Tampilkan empty state inline jika tidak ada card cocok
739  let noCardMsg = grid.querySelector('#cardSearchEmpty');
740  if (keyword && visibleCount === 0) {
741    if (!noCardMsg) {
742      noCardMsg = document.createElement('div');
743      noCardMsg.id = 'cardSearchEmpty';
744      noCardMsg.className = 'col-span-full text-center py-8 text-slate-400 text-sm';
745      noCardMsg.innerHTML = `<div class="text-2xl mb-1">🔍</div><div>Tidak ada kartu cocok untuk <strong class="text-slate-600">"${escapeHtml(kw)}"</strong></div><div class="text-xs mt-1">Coba kata kunci lain atau kosongkan untuk reset</div>`;
746      grid.appendChild(noCardMsg);
747    } else {
748      noCardMsg.querySelector('strong').textContent = `"${kw}"`;
749      noCardMsg.style.display = '';
750    }
751  } else if (noCardMsg) {
752    noCardMsg.style.display = 'none';
753  }
754}
755
756// updateTabCounts() ada di dsh-filters.js — salinan di sini dulu menimpanya dan
757// tidak pernah mengisi badge Fasilitas, Agenda, Video, dan Karir.
758
759// ---------------------------------------------------------------------------
760// 7. UTILITY — BUILD FILTER OPTIONS
761// ---------------------------------------------------------------------------
762
763function buildTagOptions(list) {
764  const sel = document.getElementById('filterTag');
765  if (!sel) return;
766  const tagSet = new Set();
767  list.forEach(svc => {
768    if (svc.tag) svc.tag.split(',').map(t => t.trim()).filter(Boolean).forEach(t => tagSet.add(t));
769  });
770  const tags = Array.from(tagSet).sort((a, b) => a.localeCompare(b, 'id'));
771  sel.innerHTML = '<option value="all">🏷️ Tag</option>' +
772    tags.map(t => `<option value="${t}">🏷️ ${t}</option>`).join('');
773}
774
775function buildOwnerOptions(list) {
776  const sel = document.getElementById('filterOwner');
777  if (!sel) return;
778  const ownerSet = new Set();
779  list.forEach(svc => { if (svc.owner) ownerSet.add(svc.owner); });
780  const owners = Array.from(ownerSet).sort((a, b) => a.localeCompare(b, 'id'));
781  sel.innerHTML = '<option value="all">🏛️ Penyelenggara</option>' +
782    owners.map(o => `<option value="${o}">🏛️ ${o}</option>`).join('');
783}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.