Spaces:
Build error
Build error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Document Search</title> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: var(--sl-font-sans, system-ui, sans-serif); | |
| font-size: 14px; | |
| background: #fff; | |
| color: #333; | |
| padding: 12px; | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| } | |
| .search-bar { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| margin-bottom: 10px; | |
| } | |
| #search-input { | |
| flex: 1; | |
| padding: 6px 10px; | |
| border: 1px solid #ccc; | |
| border-radius: 4px; | |
| font-size: 14px; | |
| outline: none; | |
| } | |
| #search-input:focus { border-color: #0066cc; } | |
| #clear-btn { | |
| background: none; | |
| border: none; | |
| cursor: pointer; | |
| font-size: 18px; | |
| line-height: 1; | |
| color: #666; | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| visibility: hidden; | |
| } | |
| #clear-btn:hover { background: #eee; } | |
| #clear-btn.visible { visibility: visible; } | |
| .meta-bar { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| font-size: 12px; | |
| color: #888; | |
| margin-bottom: 8px; | |
| min-height: 18px; | |
| } | |
| #refresh-link { | |
| color: #0066cc; | |
| cursor: pointer; | |
| text-decoration: underline; | |
| background: none; | |
| border: none; | |
| font-size: 12px; | |
| padding: 0; | |
| } | |
| #results { | |
| flex: 1; | |
| overflow-y: auto; | |
| } | |
| .result-item { | |
| padding: 8px 10px; | |
| border-bottom: 1px solid #eee; | |
| cursor: pointer; | |
| line-height: 1.4; | |
| } | |
| .result-item:hover { background: #f5f8ff; } | |
| .result-item a { | |
| color: #0066cc; | |
| text-decoration: none; | |
| display: block; | |
| } | |
| .result-item a:hover { text-decoration: underline; } | |
| .result-item strong { font-weight: 600; } | |
| #status { | |
| font-style: italic; | |
| color: #999; | |
| padding: 12px 0; | |
| text-align: center; | |
| } | |
| .hint { | |
| font-size: 12px; | |
| color: #aaa; | |
| padding: 4px 0; | |
| } | |
| .tags { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 4px; | |
| margin-top: 4px; | |
| } | |
| .tag { | |
| display: inline-block; | |
| font-size: 11px; | |
| padding: 1px 6px; | |
| border-radius: 10px; | |
| background: #eef2f8; | |
| color: #445; | |
| white-space: nowrap; | |
| } | |
| .result-item.gold-standard > a::before { | |
| content: 'π₯ '; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="search-bar"> | |
| <input id="search-input" type="text" placeholder="Search⦠e.g. brahms | status:published | is_gold_standard:true" autocomplete="off" /> | |
| <button id="clear-btn" title="Clear search">Γ</button> | |
| </div> | |
| <div class="hint" style="margin-bottom:6px;font-size:11px;color:#aaa"> | |
| Filters: <code>is_gold_standard:true|false</code> | |
| <code>status:value</code> | |
| <code>variant:value</code> | |
| <code>created_by:value</code> | |
| <code>collection:id</code> | |
| <code>status:not:v1|v2</code> β combined with AND; plain terms use full-text search. | |
| </div> | |
| <div class="meta-bar"> | |
| <span id="result-count"></span> | |
| <button id="refresh-link" title="Rebuild search index">Refresh index</button> | |
| </div> | |
| <div id="results"> | |
| <div id="status" class="hint">Type at least 4 characters to search.</div> | |
| </div> | |
| <script type="module"> | |
| /** @import { PluginSandbox } from '../../../../app/src/modules/backend-plugin-sandbox.js' */ | |
| const STORAGE_KEY = 'document-search.lastQuery'; | |
| const MIN_LENGTH = 1; | |
| const MIN_TEXT_LENGTH = 4; // plain text terms need at least 4 chars; DSL filters exempt | |
| const DEBOUNCE_MS = 300; | |
| const searchInput = document.getElementById('search-input'); | |
| const clearBtn = document.getElementById('clear-btn'); | |
| const resultsEl = document.getElementById('results'); | |
| const statusEl = document.getElementById('status'); | |
| const resultCountEl = document.getElementById('result-count'); | |
| const refreshLink = document.getElementById('refresh-link'); | |
| const params = new URLSearchParams(location.search); | |
| const sessionId = params.get('session_id') || ''; | |
| // ββ Sandbox interaction βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const sandbox = /** @type {PluginSandbox} */ (window.sandbox); | |
| function openDocument(xmlId, pdfId) { | |
| sandbox.openDocument(xmlId, pdfId).catch(err => { | |
| console.warn('openDocument error (may be non-fatal lock release):', err); | |
| }); | |
| } | |
| function updateResultState(collections, variant) { | |
| const stateUpdates = {}; | |
| if (collections && collections.length) { | |
| const currentFilter = sandbox.getCurrentState().collectionFilter; | |
| const collection = collections.includes(currentFilter) ? currentFilter : collections[0]; | |
| stateUpdates.collection = collection; | |
| stateUpdates.collectionFilter = collection; | |
| } | |
| if (variant) { | |
| stateUpdates.variant = variant; | |
| } | |
| if (Object.keys(stateUpdates).length) { | |
| sandbox.updateState(stateUpdates); | |
| } | |
| } | |
| // ββ Index cleanup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| window.addEventListener('beforeunload', () => { | |
| if (sessionId) { | |
| navigator.sendBeacon( | |
| `/api/plugins/document-search/cache/clear?session_id=${encodeURIComponent(sessionId)}` | |
| ); | |
| } | |
| }); | |
| // ββ Search logic ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let debounceTimer = null; | |
| let currentQuery = ''; | |
| function setStatus(msg) { | |
| statusEl.style.display = ''; | |
| statusEl.textContent = msg; | |
| } | |
| function clearStatus() { | |
| statusEl.style.display = 'none'; | |
| } | |
| function renderResults(items) { | |
| resultsEl.innerHTML = ''; | |
| resultsEl.appendChild(statusEl); | |
| if (items.length === 0) { | |
| setStatus('No results found.'); | |
| resultCountEl.textContent = ''; | |
| return; | |
| } | |
| clearStatus(); | |
| resultCountEl.textContent = `${items.length} result${items.length !== 1 ? 's' : ''}`; | |
| items.forEach(({ xml_id, pdf_id, match, is_gold, status, variant, created_by, collections }) => { | |
| const div = document.createElement('div'); | |
| div.className = 'result-item' + (is_gold ? ' gold-standard' : ''); | |
| const a = document.createElement('a'); | |
| a.href = '#'; | |
| a.innerHTML = match; | |
| a.addEventListener('click', e => { | |
| e.preventDefault(); | |
| updateResultState(collections, variant); | |
| openDocument(xml_id, pdf_id); | |
| }); | |
| div.appendChild(a); | |
| const tags = []; | |
| if (collections && collections.length) { | |
| tags.push(`collection: ${collections.join(', ')}`); | |
| } | |
| if (variant) { | |
| tags.push(`variant: ${variant}`); | |
| } | |
| if (created_by) tags.push(`created_by: ${created_by}`); | |
| if (status) tags.push(`status: ${status}`); | |
| if (tags.length) { | |
| const tagsDiv = document.createElement('div'); | |
| tagsDiv.className = 'tags'; | |
| tags.forEach(t => { | |
| const span = document.createElement('span'); | |
| span.className = 'tag'; | |
| span.textContent = t; | |
| tagsDiv.appendChild(span); | |
| }); | |
| div.appendChild(tagsDiv); | |
| } | |
| resultsEl.appendChild(div); | |
| }); | |
| } | |
| async function runSearch(q, bustCache) { | |
| if (bustCache) { | |
| await fetch( | |
| `/api/plugins/document-search/cache/clear?session_id=${encodeURIComponent(sessionId)}`, | |
| { method: 'POST' } | |
| ).catch(() => {}); | |
| } | |
| setStatus('Searchingβ¦'); | |
| resultCountEl.textContent = ''; | |
| try { | |
| const url = `/api/plugins/document-search/results?q=${encodeURIComponent(q)}&session_id=${encodeURIComponent(sessionId)}`; | |
| const resp = await fetch(url, { headers: { 'X-Session-ID': sessionId } }); | |
| if (!resp.ok) { | |
| setStatus('Search failed. Please try again.'); | |
| return; | |
| } | |
| const items = await resp.json(); | |
| renderResults(items); | |
| } catch { | |
| setStatus('Search failed. Please try again.'); | |
| } | |
| } | |
| function onInput() { | |
| const q = searchInput.value.trim(); | |
| clearBtn.classList.toggle('visible', q.length > 0); | |
| localStorage.setItem(STORAGE_KEY, q); | |
| currentQuery = q; | |
| clearTimeout(debounceTimer); | |
| // Allow query if it contains a DSL filter token (field:value) or enough plain text | |
| const hasDslFilter = /\b(?:is_gold_standard|status|variant|created_by|collection):[^\s]+/.test(q); | |
| const textPart = q.replace(/\b(?:is_gold_standard|status|variant|created_by|collection):(?:not:)?[^\s]+/g, '').trim(); | |
| const notEnough = !hasDslFilter && textPart.length < MIN_TEXT_LENGTH; | |
| if (q.length < MIN_LENGTH || notEnough) { | |
| resultsEl.innerHTML = ''; | |
| resultsEl.appendChild(statusEl); | |
| const need = MIN_TEXT_LENGTH - textPart.length; | |
| setStatus(q.length === 0 ? 'Type to search. Use field filters like status:published.' : `${need} more character${need !== 1 ? 's' : ''} needed (or add a filter like status:published).`); | |
| resultCountEl.textContent = ''; | |
| return; | |
| } | |
| debounceTimer = setTimeout(() => runSearch(q, false), DEBOUNCE_MS); | |
| } | |
| function clearSearch() { | |
| searchInput.value = ''; | |
| localStorage.setItem(STORAGE_KEY, ''); | |
| onInput(); | |
| searchInput.focus(); | |
| } | |
| // ββ Event wiring ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| searchInput.addEventListener('input', onInput); | |
| clearBtn.addEventListener('click', clearSearch); | |
| refreshLink.addEventListener('click', () => { | |
| const q = searchInput.value.trim(); | |
| if (q.length >= MIN_LENGTH) { | |
| runSearch(q, true); | |
| } | |
| }); | |
| // ββ Restore last query on load ββββββββββββββββββββββββββββββββββββββββββββ | |
| const lastQuery = localStorage.getItem(STORAGE_KEY) || ''; | |
| if (lastQuery) { | |
| searchInput.value = lastQuery; | |
| clearBtn.classList.add('visible'); | |
| currentQuery = lastQuery; | |
| if (lastQuery.length >= MIN_LENGTH) { | |
| runSearch(lastQuery, false); | |
| } | |
| } | |
| searchInput.focus(); | |
| </script> | |
| </body> | |
| </html> | |