cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
11 kB
<!DOCTYPE html>
<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> &nbsp;
<code>status:value</code> &nbsp;
<code>variant:value</code> &nbsp;
<code>created_by:value</code> &nbsp;
<code>collection:id</code> &nbsp;
<code>status:not:v1|v2</code> &nbsp;β€”&nbsp; 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>