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.