codebook / potato /static /search.js
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
6.45 kB
/*
* Annotator search-and-claim sidebar.
*
* Self-gating: on init it probes GET /api/search with no query. The
* endpoint returns 400 ("q required") when annotator search-and-claim
* is enabled and the user is authenticated, and 403/401/503 otherwise.
* The toggle is only revealed on 400, so the feature stays invisible
* unless search.annotator_claim is on.
*/
(function () {
"use strict";
var API = "/api/search";
function el(id) { return document.getElementById(id); }
function esc(s) {
var d = document.createElement("div");
d.textContent = s == null ? "" : String(s);
return d.innerHTML;
}
// FTS5 marks the matched term with the STX/ETX sentinels (see
// potato/search/fts5.py). Escape the whole snippet FIRST so any real
// markup in the text is neutralised, THEN swap our own sentinels for a
// <mark> highlight — keeping it XSS-safe while showing where it matched.
var SNIP_OPEN = String.fromCharCode(2); // STX, matches fts5.py
var SNIP_CLOSE = String.fromCharCode(3); // ETX
function highlightSnippet(s) {
return esc(s)
.split(SNIP_OPEN).join('<mark class="search-hit">')
.split(SNIP_CLOSE).join("</mark>");
}
function setStatus(html) {
var box = el("search-results");
if (box) box.innerHTML = html;
}
function renderResults(data) {
var box = el("search-results");
if (!box) return;
var hits = (data && data.results) || [];
if (!hits.length) {
box.innerHTML = '<div class="search-empty">No matches. Try a '
+ "different word or phrase.</div>";
return;
}
box.innerHTML = hits.map(function (h) {
return '<div class="search-item">'
+ '<div class="search-snippet">' + highlightSnippet(h.snippet)
+ "</div>"
+ '<div class="search-row">'
+ '<span class="search-id">' + esc(h.instance_id) + "</span>"
+ '<button type="button" class="search-claim" data-id="'
+ esc(h.instance_id) + '">Claim</button>'
+ "</div></div>";
}).join("");
box.querySelectorAll(".search-claim").forEach(function (b) {
b.addEventListener("click", function () { claim(b); });
});
}
function claim(btn) {
var id = btn.getAttribute("data-id");
btn.disabled = true;
var prev = btn.textContent;
btn.textContent = "Claiming…";
fetch(API + "/claim", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instance_id: id }),
}).then(function (r) {
if (!r.ok) { failClaim(btn, prev); return; }
var span = document.createElement("span");
span.className = "search-claimed";
span.textContent = "✓ In your queue";
btn.replaceWith(span);
}).catch(function () { failClaim(btn, prev); });
}
function failClaim(btn, label) {
var note = document.createElement("span");
note.className = "search-claim-error";
note.textContent = "Couldn't claim — retry";
note.setAttribute("role", "button");
note.setAttribute("tabindex", "0");
function retry() {
var b = document.createElement("button");
b.type = "button";
b.className = "search-claim";
b.setAttribute("data-id", btn.getAttribute("data-id"));
b.textContent = label;
b.addEventListener("click", function () { claim(b); });
note.replaceWith(b);
b.focus();
}
note.addEventListener("click", retry);
note.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); retry(); }
});
btn.replaceWith(note);
}
function doSearch() {
var input = el("search-q");
var go = el("search-go");
var q = (input && input.value || "").trim();
if (!q) { if (input) input.focus(); return; }
if (go) go.disabled = true;
setStatus('<div class="search-loading" role="status">Searching…</div>');
fetch(API + "?q=" + encodeURIComponent(q) + "&limit=50")
.then(function (r) { return r.ok ? r.json() : { results: [] }; })
.then(renderResults)
.catch(function () { renderResults({ results: [] }); })
.finally(function () { if (go) go.disabled = false; });
}
function closePanel() {
var panel = el("search-panel");
var toggle = el("search-panel-toggle");
if (panel) panel.hidden = true;
if (toggle) { toggle.hidden = false; toggle.focus(); }
}
function wire() {
var toggle = el("search-panel-toggle");
var panel = el("search-panel");
var close = el("search-panel-close");
if (toggle && panel) {
toggle.addEventListener("click", function () {
panel.hidden = false;
toggle.hidden = true;
var q = el("search-q");
if (q) q.focus();
});
}
if (close) close.addEventListener("click", closePanel);
if (panel) {
panel.addEventListener("keydown", function (e) {
if (e.key === "Escape") { e.preventDefault(); closePanel(); }
});
}
var go = el("search-go");
if (go) go.addEventListener("click", doSearch);
var input = el("search-q");
if (input) {
input.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); doSearch(); }
});
}
}
function init() {
if (!window.config || !window.config.is_annotation_page) return;
if (!el("search-panel")) return;
// Enable-probe: 400 => enabled+authed; anything else => stay hidden.
fetch(API).then(function (r) {
if (r.status === 400) {
var t = el("search-panel-toggle");
if (t) t.hidden = false;
wire();
}
}).catch(function () { /* leave hidden */ });
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();