knight 7f74aaced8
Some checks failed
docker-build / build (push) Has been cancelled
Persist search settings locally
2025-11-19 10:20:00 -05:00

1729 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
// Dim mode toggle (minimize button)
const minimizeBtn = document.getElementById("minimizeBtn");
function getDimMode() {
return localStorage.getItem("dim-mode") === "true";
}
function setDimMode(enabled) {
if (enabled) {
document.body.classList.add("dimmed");
} else {
document.body.classList.remove("dimmed");
}
localStorage.setItem("dim-mode", enabled.toString());
}
function toggleDimMode() {
const current = document.body.classList.contains("dimmed");
setDimMode(!current);
}
// Initialize dim mode
if (getDimMode()) {
document.body.classList.add("dimmed");
}
// Listen for minimize button click
if (minimizeBtn) {
minimizeBtn.addEventListener("click", toggleDimMode);
}
let qs = new URLSearchParams(window.location.search);
const qInput = document.getElementById("q");
const channelSelect = document.getElementById("channel");
const yearSel = document.getElementById("year");
const sortSel = document.getElementById("sort");
const sizeSel = document.getElementById("size");
const exactToggle = document.getElementById("exactToggle");
const fuzzyToggle = document.getElementById("fuzzyToggle");
const phraseToggle = document.getElementById("phraseToggle");
const externalToggle = document.getElementById("externalToggle");
const queryToggle = document.getElementById("queryStringToggle");
const searchBtn = document.getElementById("searchBtn");
const aboutBtn = document.getElementById("aboutBtn");
const aboutPanel = document.getElementById("aboutPanel");
const aboutCloseBtn = document.getElementById("aboutCloseBtn");
const resultsDiv = document.getElementById("results");
const metaDiv = document.getElementById("meta");
const metricsContainer = document.getElementById("metrics");
const metricsStatus = document.getElementById("metricsStatus");
const metricsContent = document.getElementById("metricsContent");
const freqSummary = document.getElementById("frequencySummary");
const freqChart = document.getElementById("frequencyChart");
const graphOverlay = document.getElementById("graphModalOverlay");
const graphModalClose = document.getElementById("graphModalClose");
const channelMap = new Map();
const transcriptCache = new Map();
const SETTINGS_KEY = "tlc-search-settings";
const DEFAULT_SETTINGS = {
channel: "",
year: "",
sort: "relevant",
size: "10",
exact: true,
fuzzy: true,
phrase: true,
external: false,
queryString: false,
};
let settings = loadSettings();
let lastFocusBeforeModal = null;
let pendingChannelSelection = "";
let channelsReady = false;
let previousToggleState = {
exact: settings.exact,
fuzzy: settings.fuzzy,
phrase: settings.phrase,
};
let currentPage = 0;
function toggleAboutPanel(show) {
if (!aboutPanel) return;
if (show) {
aboutPanel.removeAttribute("hidden");
} else {
aboutPanel.setAttribute("hidden", "hidden");
}
}
function parseChannelParam(params) {
if (!params) return "";
const seen = new Set();
const rawValues = params.getAll("channel_id");
const legacy = params.get("channel");
if (legacy) rawValues.push(legacy);
rawValues.forEach((value) => {
if (value == null) return;
String(value)
.split(",")
.map((part) => part.trim())
.filter((part) => part && part.toLowerCase() !== "all")
.forEach((part) => {
if (!seen.has(part)) {
seen.add(part);
}
});
});
const first = Array.from(seen)[0];
return first || "";
}
function loadSettings() {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
const parsed = JSON.parse(raw);
return { ...DEFAULT_SETTINGS, ...parsed };
} catch (err) {
console.warn("Failed to load settings", err);
return { ...DEFAULT_SETTINGS };
}
}
function persistSettings() {
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch (err) {
console.warn("Failed to persist settings", err);
}
}
function applyStoredSettings() {
yearSel.value = settings.year || "";
sortSel.value = settings.sort || "relevant";
sizeSel.value = settings.size || "10";
exactToggle.checked = settings.exact;
fuzzyToggle.checked = settings.fuzzy;
phraseToggle.checked = settings.phrase;
if (externalToggle) {
externalToggle.checked = settings.external;
}
if (queryToggle) {
queryToggle.checked = settings.queryString;
}
}
function currentTogglePreferences() {
if (queryToggle && queryToggle.checked) {
return { ...previousToggleState };
}
return {
exact: !!exactToggle.checked,
fuzzy: !!fuzzyToggle.checked,
phrase: !!phraseToggle.checked,
};
}
function syncSettingsFromControls() {
const togglePrefs = currentTogglePreferences();
const next = {
...settings,
channel: channelSelect ? channelSelect.value || "" : "",
year: yearSel.value || "",
sort: sortSel.value || "relevant",
size: sizeSel.value || "10",
external: externalToggle ? !!externalToggle.checked : false,
queryString: queryToggle ? !!queryToggle.checked : false,
...togglePrefs,
};
settings = next;
persistSettings();
return settings;
}
function getSelectedChannels() {
if (!channelSelect) return [];
const value = channelSelect.value;
return value ? [value] : [];
}
async function loadYears() {
if (!yearSel) return;
try {
const res = await fetch("/api/years");
const data = await res.json();
// Keep the "All Years" option
yearSel.innerHTML = '<option value="">All Years</option>';
data.forEach((item) => {
const option = document.createElement("option");
option.value = item.Year;
option.textContent = `${item.Year} (${item.Count})`;
yearSel.appendChild(option);
});
} catch (err) {
console.error("Failed to load years", err);
}
}
function setFromQuery() {
qInput.value = qs.get("q") || "";
const urlChannel = parseChannelParam(qs);
if (urlChannel) {
pendingChannelSelection = urlChannel;
settings.channel = urlChannel;
persistSettings();
} else {
pendingChannelSelection = settings.channel || "";
}
applyStoredSettings();
if (channelSelect) {
channelSelect.value = pendingChannelSelection || "";
}
applyQueryMode();
rememberToggleState();
}
function applyQueryMode() {
if (!queryToggle) return;
if (queryToggle.checked) {
if (!exactToggle.disabled) {
previousToggleState = {
exact: exactToggle.checked,
fuzzy: fuzzyToggle.checked,
phrase: phraseToggle.checked,
};
settings = { ...settings, ...previousToggleState };
persistSettings();
}
exactToggle.checked = false;
fuzzyToggle.checked = false;
phraseToggle.checked = false;
exactToggle.disabled = true;
fuzzyToggle.disabled = true;
phraseToggle.disabled = true;
} else {
exactToggle.disabled = false;
fuzzyToggle.disabled = false;
phraseToggle.disabled = false;
exactToggle.checked = previousToggleState.exact;
fuzzyToggle.checked = previousToggleState.fuzzy;
phraseToggle.checked = previousToggleState.phrase;
}
settings.queryString = !!(queryToggle && queryToggle.checked);
persistSettings();
}
function rememberToggleState() {
if (queryToggle && !queryToggle.checked) {
previousToggleState = {
exact: !!exactToggle.checked,
fuzzy: !!fuzzyToggle.checked,
phrase: !!phraseToggle.checked,
};
settings = { ...settings, ...previousToggleState };
persistSettings();
}
}
function graphUiAvailable() {
return !!(window.GraphUI && window.GraphUI.ready);
}
function openGraphModal(videoId) {
if (!graphOverlay || !graphUiAvailable()) {
return;
}
lastFocusBeforeModal =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
graphOverlay.classList.add("active");
graphOverlay.setAttribute("aria-hidden", "false");
document.body.classList.add("modal-open");
window.requestAnimationFrame(() => {
window.GraphUI.setDepth(1);
window.GraphUI.setMaxNodes(200);
window.GraphUI.setLabelSize("tiny");
const graphVideoField = document.getElementById("graphVideoId");
if (videoId && graphVideoField) {
graphVideoField.value = videoId;
}
if (videoId) {
window.GraphUI.load(videoId, undefined, undefined, { updateInputs: true });
}
window.GraphUI.focusInput();
});
}
function closeGraphModal() {
if (!graphOverlay) {
return;
}
graphOverlay.classList.remove("active");
graphOverlay.setAttribute("aria-hidden", "true");
document.body.classList.remove("modal-open");
if (graphUiAvailable()) {
window.GraphUI.stop();
}
if (lastFocusBeforeModal && typeof lastFocusBeforeModal.focus === "function") {
lastFocusBeforeModal.focus();
}
lastFocusBeforeModal = null;
}
if (graphModalClose) {
graphModalClose.addEventListener("click", closeGraphModal);
}
if (graphOverlay) {
graphOverlay.addEventListener("click", (event) => {
if (event.target === graphOverlay) {
closeGraphModal();
}
});
}
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && graphOverlay && graphOverlay.classList.contains("active")) {
closeGraphModal();
}
});
window.addEventListener("graph-ui-ready", () => {
document
.querySelectorAll('.graph-launch-btn[data-await-graph-ready="1"]')
.forEach((btn) => {
btn.removeAttribute("disabled");
btn.removeAttribute("data-await-graph-ready");
btn.title = "Open reference graph";
});
});
function ensureQueryStringMode() {
if (!queryToggle) return;
if (!queryToggle.checked) {
rememberToggleState();
queryToggle.checked = true;
applyQueryMode();
}
}
function escapeQueryValue(value) {
return value.replace(/(["\\])/g, "\\$1");
}
function buildFieldClause(field, ids) {
if (!Array.isArray(ids)) return null;
const seen = new Set();
const collected = [];
ids.forEach((raw) => {
if (!raw && raw !== 0) return;
const value = String(raw).trim();
if (!value) return;
if (seen.has(value)) return;
seen.add(value);
collected.push(value);
});
if (!collected.length) return null;
const escaped = collected.map((id) => `"${escapeQueryValue(id)}"`);
const variants = field.endsWith(".keyword")
? [field]
: [`${field}.keyword`, field];
const clauses = variants.map((fname) => `${fname}:(${escaped.join(" OR ")})`);
return clauses.length > 1 ? `(${clauses.join(" OR ")})` : clauses[0];
}
async function loadChannels() {
if (!channelSelect) {
channelsReady = true;
return;
}
try {
const includeExternal = externalToggle ? externalToggle.checked : false;
const res = await fetch(`/api/channels?external=${includeExternal ? "1" : "0"}`);
const data = await res.json();
channelMap.clear();
channelSelect.innerHTML = '<option value="">All Channels</option>';
data.forEach((item) => {
const option = document.createElement("option");
option.value = item.Id;
option.textContent = `${item.Name} (${item.Count})`;
channelSelect.appendChild(option);
channelMap.set(item.Id, item.Name);
});
if (pendingChannelSelection && channelMap.has(pendingChannelSelection)) {
channelSelect.value = pendingChannelSelection;
} else {
channelSelect.value = "";
}
settings.channel = channelSelect.value || "";
persistSettings();
channelsReady = true;
} catch (err) {
console.error("Failed to load channels", err);
channelSelect.innerHTML = '<option value="">All Channels</option>';
channelsReady = true;
}
}
function updateUrl(q) {
const next = new URL(window.location.href);
if (q) {
next.searchParams.set("q", q);
} else {
next.searchParams.delete("q");
}
next.searchParams.delete("page");
next.searchParams.delete("sort");
next.searchParams.delete("channel_id");
next.searchParams.delete("channel");
next.searchParams.delete("year");
next.searchParams.delete("size");
next.searchParams.delete("exact");
next.searchParams.delete("fuzzy");
next.searchParams.delete("phrase");
next.searchParams.delete("query_string");
next.searchParams.delete("external");
history.pushState({}, "", next.toString());
}
function fmtDate(value) {
try {
return (value || "").split("T")[0];
} catch {
return value;
}
}
function fmtNumber(n) {
if (typeof n === "number") return n.toLocaleString();
return n;
}
async function getTranscriptData(videoId) {
if (!videoId) return null;
if (transcriptCache.has(videoId)) {
return transcriptCache.get(videoId);
}
const res = await fetch(`/api/transcript?video_id=${encodeURIComponent(videoId)}`);
if (!res.ok) {
throw new Error(`Transcript fetch failed (${res.status})`);
}
const data = await res.json();
transcriptCache.set(videoId, data);
return data;
}
function formatMlaDate(value) {
if (!value) return "";
const parsed = new Date(value);
if (Number.isNaN(parsed.valueOf())) {
return value;
}
const months = [
"Jan.", "Feb.", "Mar.", "Apr.", "May", "June",
"July", "Aug.", "Sept.", "Oct.", "Nov.", "Dec.",
];
return `${parsed.getDate()} ${months[parsed.getMonth()]} ${parsed.getFullYear()}`;
}
function buildMlaCitation(item) {
const channel = (item.channel_name || item.channel_id || "Unknown channel").trim();
const title = (item.title || "Untitled").trim();
const url = item.url || "";
const publishDate = formatMlaDate(item.date) || "n.d.";
const today = formatMlaDate(new Date().toISOString().split("T")[0]);
return `${channel}. "${title}." YouTube, uploaded by ${channel}, ${publishDate}, ${url}. Accessed ${today}.`;
}
function formatSegmentTimestamp(segment) {
if (!segment) return "";
if (segment.timestamp) return segment.timestamp;
const candidates = [
segment.start_seconds,
segment.start,
segment.offset,
segment.time,
];
for (const value of candidates) {
if (value == null) continue;
const seconds = parseFloat(value);
if (!Number.isNaN(seconds)) {
return formatTimestamp(seconds);
}
}
return "";
}
function serializeTranscriptSection(label, parts, fullText) {
let content = "";
if (typeof fullText === "string" && fullText.trim()) {
content = fullText.trim();
} else if (Array.isArray(parts) && parts.length) {
content = parts
.map((segment) => {
const ts = formatSegmentTimestamp(segment);
const text = segment && segment.text ? segment.text : "";
return ts ? `[${ts}] ${text}` : text;
})
.join("\n")
.trim();
}
if (!content) {
return "";
}
return `${label}\n${content}\n`;
}
function buildTranscriptDownloadText(item, transcriptData) {
const lines = [];
lines.push(`Title: ${item.title || "Untitled"}`);
if (item.channel_name) {
lines.push(`Channel: ${item.channel_name}`);
}
if (item.date) {
lines.push(`Published: ${item.date}`);
}
if (item.url) {
lines.push(`URL: ${item.url}`);
}
lines.push("");
const primaryText = serializeTranscriptSection(
"Primary Transcript",
transcriptData.transcript_parts,
transcriptData.transcript_full
);
const secondaryText = serializeTranscriptSection(
"Secondary Transcript",
transcriptData.transcript_secondary_parts,
transcriptData.transcript_secondary_full
);
if (primaryText) {
lines.push(primaryText);
}
if (secondaryText) {
lines.push(secondaryText);
}
if (!primaryText && !secondaryText) {
lines.push("No transcript available.");
}
return lines.join("\n").trim() + "\n";
}
function flashButtonMessage(button, message, duration = 1800) {
if (!button) return;
const original = button.dataset.originalLabel || button.textContent;
button.dataset.originalLabel = original;
button.textContent = message;
setTimeout(() => {
button.textContent = button.dataset.originalLabel || original;
}, duration);
}
async function handleTranscriptDownload(item, button) {
if (!item.video_id) return;
button.disabled = true;
try {
const data = await getTranscriptData(item.video_id);
if (!data) {
throw new Error("Transcript unavailable");
}
const text = buildTranscriptDownloadText(item, data);
const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${item.video_id || "transcript"}.txt`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
flashButtonMessage(button, "Downloaded");
} catch (err) {
console.error("Download failed", err);
console.error("Download failed", err);
alert("Unable to download transcript right now.");
} finally {
button.disabled = false;
}
}
async function handleCopyCitation(item, button) {
const citation = buildMlaCitation(item);
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(citation);
} else {
const textarea = document.createElement("textarea");
textarea.value = citation;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
flashButtonMessage(button, "Copied!");
} catch (err) {
console.error("Citation copy failed", err);
alert(citation);
}
}
function getVideoStatus(item) {
if (!item || !item.video_status) return "";
return String(item.video_status).toLowerCase();
}
function isLikelyDeleted(item) {
return getVideoStatus(item) === "deleted";
}
function formatTimestamp(seconds) {
if (!seconds && seconds !== 0) return "00:00";
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function getYouTubeTimestampUrl(baseUrl, seconds) {
if (!baseUrl) return '#';
const url = new URL(baseUrl);
url.searchParams.set('t', Math.floor(seconds) + 's');
return url.toString();
}
function parseTimestampToSeconds(timestamp) {
// Handle string timestamps like "00:00:39.480" or "HH:MM:SS.mmm"
if (typeof timestamp === 'string' && timestamp.includes(':')) {
const parts = timestamp.split(':');
if (parts.length === 3) {
const hours = parseFloat(parts[0]) || 0;
const minutes = parseFloat(parts[1]) || 0;
const seconds = parseFloat(parts[2]) || 0;
return hours * 3600 + minutes * 60 + seconds;
} else if (parts.length === 2) {
const minutes = parseFloat(parts[0]) || 0;
const seconds = parseFloat(parts[1]) || 0;
return minutes * 60 + seconds;
}
}
// Handle numeric timestamps
const num = parseFloat(timestamp);
if (!isNaN(num)) {
// If timestamp is in milliseconds (> 10000 for timestamps after ~2.7 hours), convert to seconds
return num > 10000 ? num / 1000 : num;
}
return 0;
}
function renderTranscriptSegment(segment, videoUrl) {
const segmentDiv = document.createElement('div');
segmentDiv.className = 'transcript-segment';
segmentDiv.dataset.text = (segment.text || '').toLowerCase();
// Try different timestamp field names
let startSeconds = 0;
const possibleFields = [
segment.timestamp, // Primary field (string format "HH:MM:SS.mmm")
segment.start_seconds,
segment.start,
segment.offset,
segment.time,
segment.startTime
];
for (const field of possibleFields) {
if (field != null) {
const parsed = parseTimestampToSeconds(field);
if (parsed > 0 || (parsed === 0 && field !== undefined)) {
startSeconds = parsed;
break;
}
}
}
const timestampText = formatTimestamp(startSeconds);
const timestampUrl = getYouTubeTimestampUrl(videoUrl, startSeconds);
const timestampLink = document.createElement('a');
timestampLink.href = timestampUrl;
timestampLink.className = 'timestamp-link';
timestampLink.textContent = timestampText;
timestampLink.target = '_blank';
timestampLink.rel = 'noopener';
const textSpan = document.createElement('span');
textSpan.className = 'transcript-text';
textSpan.textContent = segment.text || '';
segmentDiv.appendChild(timestampLink);
segmentDiv.appendChild(textSpan);
return segmentDiv;
}
function stripHtmlAndNormalize(html) {
const temp = document.createElement('div');
temp.innerHTML = html;
return temp.textContent.trim().toLowerCase().replace(/\s+/g, ' ');
}
function extractMarkedText(html) {
// Extract text from <mark> tags as these are the actual search matches
const temp = document.createElement('div');
temp.innerHTML = html;
const marks = temp.querySelectorAll('mark');
if (marks.length > 0) {
return Array.from(marks).map(m => m.textContent.trim().toLowerCase()).join(' ');
}
return null;
}
function findMatchingSegment(transcriptDiv, searchText) {
const segments = Array.from(transcriptDiv.querySelectorAll('.transcript-segment'));
const normalized = searchText.toLowerCase().replace(/\s+/g, ' ').trim();
// Strategy 1: Try to match the marked/highlighted words first (most reliable)
const markedText = extractMarkedText(searchText);
if (markedText) {
const markedWords = markedText.split(' ').filter(w => w.length > 2);
let bestMatch = null;
let bestScore = 0;
for (const segment of segments) {
const segmentText = segment.dataset.text;
if (!segmentText) continue;
let matchCount = 0;
for (const word of markedWords) {
if (segmentText.includes(word)) {
matchCount++;
}
}
const score = matchCount / markedWords.length;
if (score > bestScore) {
bestScore = score;
bestMatch = segment;
}
}
// If we found a good match with marked words, use it
if (bestMatch && bestScore >= 0.7) {
return bestMatch;
}
}
// Strategy 2: Try exact substring match
for (const segment of segments) {
const segmentText = segment.dataset.text;
if (segmentText && segmentText.includes(normalized)) {
return segment;
}
}
// Strategy 3: Try matching a sliding window of the search text
// (since highlights may span multiple segments, try smaller chunks)
const words = normalized.split(' ');
if (words.length > 10) {
// Try chunks of 8 consecutive words from the middle (most likely to be in one segment)
const chunkSize = 8;
const startIdx = Math.floor((words.length - chunkSize) / 2);
const chunk = words.slice(startIdx, startIdx + chunkSize).join(' ');
for (const segment of segments) {
const segmentText = segment.dataset.text;
if (segmentText && segmentText.includes(chunk)) {
return segment;
}
}
}
// Strategy 4: Fuzzy word matching (at least 50% of words match)
const searchWords = normalized.split(' ').filter(w => w.length > 2);
if (searchWords.length === 0) return null;
// Take up to 15 most distinctive words (skip very common words)
const commonWords = new Set(['the', 'and', 'that', 'this', 'with', 'for', 'are', 'but', 'not', 'you', 'have', 'from', 'was', 'been', 'has', 'had', 'were']);
const distinctWords = searchWords
.filter(w => !commonWords.has(w))
.slice(0, 15);
let bestMatch = null;
let bestScore = 0;
for (const segment of segments) {
const segmentText = segment.dataset.text;
if (!segmentText) continue;
let matchCount = 0;
let consecutiveMatches = 0;
let maxConsecutive = 0;
for (let i = 0; i < distinctWords.length; i++) {
if (segmentText.includes(distinctWords[i])) {
matchCount++;
consecutiveMatches++;
maxConsecutive = Math.max(maxConsecutive, consecutiveMatches);
} else {
consecutiveMatches = 0;
}
}
// Score considers both match percentage and consecutive matches (phrase matches)
const matchScore = matchCount / distinctWords.length;
const consecutiveBonus = maxConsecutive / distinctWords.length * 0.3;
const score = matchScore + consecutiveBonus;
if (score > bestScore && score >= 0.4) {
bestScore = score;
bestMatch = segment;
}
}
return bestMatch;
}
function scrollToSegment(segment) {
if (!segment) return;
// Remove any existing focus
const previousFocused = segment.parentElement.querySelectorAll('.transcript-segment.focused');
previousFocused.forEach(s => s.classList.remove('focused'));
// Add focus to this segment
segment.classList.add('focused');
// Scroll to it
segment.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Remove focus after animation
setTimeout(() => {
segment.classList.remove('focused');
}, 3000);
}
const COMMON_STOP_WORDS = new Set([
"the","and","that","this","with","for","are","but","not","you","your","they","their",
"have","from","was","been","has","had","were","about","what","when","where","which",
"will","would","there","here","into","them","then","than","also","more","some","just",
"like","said","because","make","made","could","should","might"
]);
const tokenizeContent = (text) => {
if (!text) return [];
return text
.toLowerCase()
.split(/[^a-z0-9]+/g)
.filter((token) => token.length > 2 && !COMMON_STOP_WORDS.has(token))
.slice(0, 20);
};
function collectHighlightTokens(entries) {
const collected = [];
if (!Array.isArray(entries)) return collected;
entries.forEach((entry) => {
const raw = typeof entry === "string" ? entry : entry?.html || entry?.text || "";
if (!raw) return;
const marked = extractMarkedText(raw);
if (marked) {
collected.push(...tokenizeContent(marked));
} else {
collected.push(...tokenizeContent(stripHtmlAndNormalize(raw)));
}
});
return collected;
}
function buildQueryTokens(query) {
return tokenizeContent(query || "").slice(0, 20);
}
function highlightTranscriptMatches(transcriptDiv, entries, searchQuery) {
if (!transcriptDiv) return;
const tokens = new Set();
collectHighlightTokens(entries).forEach((token) => tokens.add(token));
buildQueryTokens(searchQuery).forEach((token) => tokens.add(token));
if (!tokens.size) return;
const segments = transcriptDiv.querySelectorAll(".transcript-segment");
segments.forEach((segment) => {
const text = segment.dataset.text || "";
const matched = Array.from(tokens).some((token) => text.includes(token));
segment.classList.toggle("transcript-segment--matched", matched);
});
}
async function fetchAndDisplayTranscript(
videoId,
videoUrl,
containerElement,
button,
highlightText = null,
allHighlights = null,
searchQuery = ""
) {
const existingTranscript = containerElement.querySelector('.full-transcript');
if (existingTranscript && !highlightText) {
existingTranscript.remove();
button.textContent = 'View Full Transcript';
return;
}
// If transcript exists and we have highlight text, just scroll to it
if (existingTranscript && highlightText) {
highlightTranscriptMatches(existingTranscript, allHighlights, searchQuery);
const segment = findMatchingSegment(existingTranscript, highlightText);
if (segment) {
scrollToSegment(segment);
}
return;
}
button.disabled = true;
button.textContent = 'Loading...';
try {
const res = await fetch(`/api/transcript?video_id=${encodeURIComponent(videoId)}`);
if (!res.ok) {
throw new Error(`Failed to fetch transcript: ${res.status}`);
}
const data = await res.json();
const transcriptDiv = document.createElement('div');
transcriptDiv.className = 'full-transcript';
const header = document.createElement('div');
header.className = 'transcript-header';
const title = document.createElement('span');
title.textContent = 'Full Transcript';
const closeBtn = document.createElement('span');
closeBtn.className = 'transcript-close';
closeBtn.textContent = '×';
closeBtn.title = 'Close transcript';
closeBtn.onclick = () => {
transcriptDiv.remove();
button.textContent = 'View Full Transcript';
button.disabled = false;
};
header.appendChild(title);
header.appendChild(closeBtn);
transcriptDiv.appendChild(header);
const primaryParts = data.transcript_parts || [];
const secondaryParts = data.transcript_secondary_parts || [];
if (!primaryParts.length && !secondaryParts.length) {
const noTranscript = document.createElement('div');
noTranscript.className = 'muted';
noTranscript.textContent = 'No transcript available for this video.';
transcriptDiv.appendChild(noTranscript);
} else {
if (primaryParts.length) {
const primaryHeader = document.createElement('div');
primaryHeader.style.marginBottom = '8px';
primaryHeader.style.fontWeight = 'bold';
primaryHeader.style.fontSize = '12px';
primaryHeader.style.color = '#666';
primaryHeader.textContent = 'Primary Transcript';
transcriptDiv.appendChild(primaryHeader);
primaryParts.forEach(segment => {
transcriptDiv.appendChild(renderTranscriptSegment(segment, videoUrl));
});
}
if (secondaryParts.length) {
const secondaryHeader = document.createElement('div');
secondaryHeader.style.marginTop = primaryParts.length ? '16px' : '0';
secondaryHeader.style.marginBottom = '8px';
secondaryHeader.style.fontWeight = 'bold';
secondaryHeader.style.fontSize = '12px';
secondaryHeader.style.color = '#666';
secondaryHeader.textContent = 'Secondary Transcript';
transcriptDiv.appendChild(secondaryHeader);
// Debug: log first secondary segment structure
if (secondaryParts[0]) {
console.log('Secondary transcript segment structure:', secondaryParts[0]);
}
secondaryParts.forEach(segment => {
transcriptDiv.appendChild(renderTranscriptSegment(segment, videoUrl));
});
}
}
containerElement.appendChild(transcriptDiv);
button.textContent = 'Hide Transcript';
button.disabled = false;
// If highlight text provided, scroll to it after a brief delay
if (highlightText) {
setTimeout(() => {
const segment = findMatchingSegment(transcriptDiv, highlightText);
if (segment) {
scrollToSegment(segment);
}
}, 100);
}
highlightTranscriptMatches(transcriptDiv, allHighlights, searchQuery);
} catch (err) {
console.error('Error fetching transcript:', err);
button.textContent = 'View Full Transcript';
button.disabled = false;
alert('Failed to load transcript. Please try again.');
}
}
function renderMetrics(data) {
if (!metricsContent) return;
metricsContent.innerHTML = "";
if (!data) return;
if (metricsStatus) {
metricsStatus.textContent = "";
}
const summary = document.createElement("div");
summary.innerHTML = `<strong>Entries:</strong> ${fmtNumber(data.totalItems)} • <strong>Channels:</strong> ${fmtNumber(data.totalChannels)}`;
metricsContent.appendChild(summary);
if (Array.isArray(data.itemsPerChannel) && data.itemsPerChannel.length) {
const top = data.itemsPerChannel.slice(0, 5);
const channelHeader = document.createElement("div");
channelHeader.style.marginTop = "8px";
channelHeader.innerHTML = "<strong>Top Channels</strong>";
metricsContent.appendChild(channelHeader);
const channelList = document.createElement("div");
channelList.className = "muted";
top.forEach((entry) => {
const row = document.createElement("div");
row.textContent = `${entry.label}: ${fmtNumber(entry.count)}`;
channelList.appendChild(row);
});
metricsContent.appendChild(channelList);
}
}
async function loadMetrics() {
if (!metricsContainer) return;
metricsContainer.dataset.loading = "1";
if (!metricsContainer.dataset.loaded && metricsStatus) {
metricsStatus.textContent = "Loading metrics…";
}
try {
const res = await fetch("/api/metrics");
const data = await res.json();
renderMetrics(data);
metricsContainer.dataset.loaded = "1";
} catch (err) {
console.error("Failed to load metrics", err);
if (!metricsContainer.dataset.loaded && metricsStatus) {
metricsStatus.textContent = "Metrics unavailable.";
}
} finally {
delete metricsContainer.dataset.loading;
}
}
function clearFrequency(message) {
if (freqSummary) {
freqSummary.textContent = message || "";
}
if (freqChart) {
freqChart.innerHTML = "";
}
}
function renderFrequencyChart(buckets, channelTotals) {
if (!freqChart || typeof d3 === "undefined") {
return;
}
freqChart.innerHTML = "";
if (!buckets.length) {
clearFrequency("No matches for this query.");
return;
}
const channelNameFallback = new Map();
(channelTotals || []).forEach((entry) => {
if (!entry || !entry.id) return;
if (entry.name) {
channelNameFallback.set(entry.id, entry.name);
}
});
buckets.forEach((bucket) => {
(bucket.channels || []).forEach((entry) => {
if (entry && entry.id && entry.name && !channelNameFallback.has(entry.id)) {
channelNameFallback.set(entry.id, entry.name);
}
});
});
const getChannelLabel = (id) => {
if (!id) return "";
return channelMap.get(id) || channelNameFallback.get(id) || id;
};
let channelsOrder =
(channelTotals && channelTotals.length
? channelTotals.map((entry) => entry.id)
: []) || [];
if (!channelsOrder.length) {
const unique = new Set();
buckets.forEach((bucket) => {
(bucket.channels || []).forEach((entry) => unique.add(entry.id));
});
channelsOrder = Array.from(unique);
}
channelsOrder = channelsOrder.slice(0, 6);
if (!channelsOrder.length) {
clearFrequency("No matches for this query.");
return;
}
const dateKeyFormat = d3.timeFormat("%Y-%m-%d");
const parsed = buckets
.map((bucket) => {
const parsedDate = d3.isoParse(bucket.date) || new Date(bucket.date);
if (!(parsedDate instanceof Date) || Number.isNaN(parsedDate.valueOf())) {
return null;
}
const counts = {};
(bucket.channels || []).forEach((entry) => {
if (channelsOrder.includes(entry.id)) {
counts[entry.id] = entry.count || 0;
}
});
return {
date: parsedDate,
dateKey: dateKeyFormat(parsedDate),
counts,
};
})
.filter(Boolean);
if (!parsed.length) {
clearFrequency("Timeline unavailable.");
return;
}
const margin = { top: 12, right: 12, bottom: 52, left: 56 };
const fullWidth = freqChart.clientWidth || 360;
const fullHeight = 220;
const width = fullWidth - margin.left - margin.right;
const height = fullHeight - margin.top - margin.bottom;
const svg = d3
.select(freqChart)
.append("svg")
.attr("width", fullWidth)
.attr("height", fullHeight);
const g = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const x = d3
.scaleBand()
.domain(parsed.map((entry) => entry.dateKey))
.range([0, width])
.padding(0.25);
const yMax = d3.max(parsed, (entry) =>
d3.sum(channelsOrder, (key) => entry.counts[key] || 0)
);
const y = d3
.scaleLinear()
.domain([0, yMax || 0])
.nice()
.range([height, 0]);
const tickValues =
parsed.length <= 6
? parsed.map((entry) => entry.dateKey)
: parsed
.filter((_, index, arr) => index % Math.ceil(arr.length / 6) === 0)
.map((entry) => entry.dateKey);
const xAxis = d3.axisBottom(x).tickValues(tickValues);
const yAxis = d3.axisLeft(y).ticks(5);
g.append("g")
.attr("class", "axis")
.attr("transform", `translate(0,${height})`)
.call(xAxis)
.selectAll("text")
.attr("text-anchor", "end")
.attr("transform", "rotate(-35)")
.attr("dx", "-0.8em")
.attr("dy", "0.15em");
g.append("g").attr("class", "axis").call(yAxis);
const stack = d3.stack().keys(channelsOrder).value((entry, key) => entry.counts[key] || 0);
const stacked = stack(parsed);
const color = d3.scaleOrdinal(channelsOrder, d3.schemeTableau10);
const layers = g
.selectAll(".freq-layer")
.data(stacked)
.enter()
.append("g")
.attr("class", "freq-layer")
.attr("fill", (d) => color(d.key));
layers
.selectAll("rect")
.data((d) => d)
.enter()
.append("rect")
.attr("x", (d) => x(d.data.dateKey))
.attr("width", x.bandwidth())
.attr("y", (d) => y(d[1]))
.attr("height", (d) => y(d[0]) - y(d[1]))
.append("title")
.text(function (d) {
const group = this.parentNode ? this.parentNode.parentNode : null;
const key = group ? d3.select(group).datum().key : undefined;
const label = key ? getChannelLabel(key) : key || '';
return `${dateKeyFormat(d.data.date)}: ${d[1] - d[0]}${label ? " (" + label + ")" : ''}`;
});
const legend = document.createElement("div");
legend.className = "freq-legend";
channelsOrder.forEach((key) => {
const item = document.createElement("div");
item.className = "freq-legend-item";
const swatch = document.createElement("span");
swatch.className = "freq-legend-swatch";
swatch.style.backgroundColor = color(key);
const label = document.createElement("span");
label.textContent = getChannelLabel(key) || key;
item.appendChild(swatch);
item.appendChild(label);
legend.appendChild(item);
});
freqChart.appendChild(legend);
}
async function updateFrequencyChart(term, channels, year, queryMode, toggles = {}) {
if (!freqChart || typeof d3 === "undefined") {
return;
}
let trimmed = term.trim();
if (!trimmed) {
if (queryMode) {
trimmed = "*";
} else {
clearFrequency("Enter a query to see timeline.");
return;
}
}
const params = new URLSearchParams();
params.set("term", trimmed);
params.set("interval", "month");
(channels || []).forEach((id) => params.append("channel_id", id));
if (year) {
params.set("year", year);
}
if (queryMode) {
params.set("query_string", "1");
}
const { exact = true, fuzzy = true, phrase = true, external = false } = toggles || {};
params.set("exact", exact ? "1" : "0");
params.set("fuzzy", fuzzy ? "1" : "0");
params.set("phrase", phrase ? "1" : "0");
params.set("external", external ? "1" : "0");
clearFrequency("Loading timeline…");
try {
const res = await fetch(`/api/frequency?${params.toString()}`);
if (!res.ok) {
throw new Error(`Request failed with status ${res.status}`);
}
const payload = await res.json();
const total = payload.totalResults || 0;
if (freqSummary) {
if (total === 0) {
freqSummary.textContent = "No matches for this query.";
} else if (queryMode) {
freqSummary.textContent = `Matches: ${total.toLocaleString()} • Interval: ${payload.interval || "month"} (query-string)`;
} else {
freqSummary.textContent = `Matches: ${total.toLocaleString()} • Interval: ${payload.interval || "month"}`;
}
}
const buckets = payload.buckets || [];
if (total === 0) {
freqChart.innerHTML = "";
return;
}
if (!buckets.length) {
clearFrequency("Timeline unavailable for this query (missing video dates).");
return;
}
renderFrequencyChart(buckets, payload.channels || []);
} catch (err) {
console.error(err);
clearFrequency("Timeline unavailable.");
}
}
function renderResults(payload, page) {
resultsDiv.innerHTML = "";
metaDiv.textContent = `Total: ${payload.totalResults} • Page ${
page + 1
} of ${payload.totalPages}`;
(payload.items || []).forEach((item) => {
const el = document.createElement("div");
el.className = "item";
const titleHtml =
item.titleHtml || escapeHtml(item.title || "Untitled");
const descriptionHtml =
item.descriptionHtml || escapeHtml(item.description || "");
const header = document.createElement("div");
header.className = "result-header";
const headerMain = document.createElement("div");
headerMain.className = "result-header-main";
const badgeDefs = [];
if (item.external_reference) {
badgeDefs.push({
label: "External",
badgeType: "external",
title: "Indexed from an external reference source",
});
}
if (item.highlightSource && item.highlightSource.primary) {
badgeDefs.push({ label: "primary transcript", badgeType: "transcript-primary" });
}
if (item.highlightSource && item.highlightSource.secondary) {
badgeDefs.push({ label: "secondary transcript", badgeType: "transcript-secondary" });
}
// Add reference count badges
const refByCount = item.referenced_by_count || 0;
const refToCount = item.internal_references_count || 0;
const refByIds = Array.isArray(item.referenced_by) ? item.referenced_by : [];
const refToIds = Array.isArray(item.internal_references) ? item.internal_references : [];
if (refByCount > 0) {
let query = buildFieldClause("video_id", refByIds);
if (!query && item.video_id) {
query = buildFieldClause("internal_references", [item.video_id]);
}
badgeDefs.push({
label: `${refByCount} backlink${refByCount !== 1 ? "s" : ""}`,
query,
title: query
? "Show videos that reference this one"
: "Reference list unavailable in this result",
});
}
if (refToCount > 0) {
const query = buildFieldClause("video_id", refToIds);
badgeDefs.push({
label: `${refToCount} reference${refToCount !== 1 ? "s" : ""}`,
query,
title: query
? "Show videos referenced by this one"
: "Reference list unavailable in this result",
});
}
const titleEl = document.createElement("strong");
titleEl.innerHTML = titleHtml;
headerMain.appendChild(titleEl);
const metaLine = document.createElement("div");
metaLine.className = "muted result-meta";
const channelLabel = item.channel_name || "";
const dateLabel = fmtDate(item.date);
let durationSeconds = null;
if (typeof item.duration === "number") {
durationSeconds = item.duration;
} else if (typeof item.duration === "string" && item.duration.trim()) {
const parsed = parseFloat(item.duration);
if (!Number.isNaN(parsed)) {
durationSeconds = parsed;
}
}
const durationLabel = durationSeconds != null ? `${formatTimestamp(durationSeconds)}` : "";
metaLine.textContent = channelLabel
? `${channelLabel}${dateLabel}${durationLabel}`
: `${dateLabel}${durationLabel}`;
if (isLikelyDeleted(item)) {
metaLine.appendChild(document.createTextNode(" "));
const statusEl = document.createElement("span");
statusEl.className = "result-status result-status--deleted";
statusEl.textContent = "Likely deleted";
statusEl.title = "YouTube reported this video as unavailable when we last checked.";
metaLine.appendChild(statusEl);
}
headerMain.appendChild(metaLine);
const linkLine = document.createElement("div");
linkLine.className = "muted";
const openLink = document.createElement("a");
openLink.href = item.url;
openLink.target = "_blank";
openLink.rel = "noopener";
openLink.textContent = "Open on YouTube";
linkLine.appendChild(openLink);
headerMain.appendChild(linkLine);
header.appendChild(headerMain);
if (badgeDefs.length) {
const badgeRow = document.createElement("div");
badgeRow.className = "badge-row";
badgeDefs.forEach((badge) => {
if (!badge || !badge.label) return;
const badgeEl = document.createElement("span");
badgeEl.className = "badge";
badgeEl.textContent = badge.label;
if (badge.title) {
badgeEl.title = badge.title;
}
if (badge.badgeType) {
badgeEl.classList.add(`badge--${badge.badgeType}`);
}
if (badge.query) {
badgeEl.classList.add("badge-clickable");
badgeEl.setAttribute("role", "button");
badgeEl.tabIndex = 0;
const triggerSearch = () => {
if (!badge.query) return;
qInput.value = badge.query;
ensureQueryStringMode();
runSearch(0);
};
badgeEl.addEventListener("click", (event) => {
event.preventDefault();
triggerSearch();
});
badgeEl.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
triggerSearch();
}
});
}
badgeRow.appendChild(badgeEl);
});
if (badgeRow.childElementCount) {
headerMain.appendChild(badgeRow);
}
}
if (item.video_id) {
const actions = document.createElement("div");
actions.className = "result-actions";
const downloadBtn = document.createElement("button");
downloadBtn.type = "button";
downloadBtn.className = "result-action-btn";
downloadBtn.textContent = "Download transcript";
downloadBtn.addEventListener("click", () => handleTranscriptDownload(item, downloadBtn));
actions.appendChild(downloadBtn);
const citationBtn = document.createElement("button");
citationBtn.type = "button";
citationBtn.className = "result-action-btn";
citationBtn.textContent = "Copy citation";
citationBtn.addEventListener("click", () => handleCopyCitation(item, citationBtn));
actions.appendChild(citationBtn);
if (graphOverlay) {
const graphBtn = document.createElement("button");
graphBtn.type = "button";
graphBtn.className = "result-action-btn graph-launch-btn";
graphBtn.textContent = "Graph";
if (graphUiAvailable()) {
graphBtn.title = "Open reference graph";
} else {
graphBtn.disabled = true;
graphBtn.title = "Reference graph is still loading…";
graphBtn.dataset.awaitGraphReady = "1";
}
graphBtn.addEventListener("click", () => openGraphModal(item.video_id));
actions.appendChild(graphBtn);
}
if (actions.childElementCount) {
header.appendChild(actions);
}
}
el.appendChild(header);
if (descriptionHtml) {
const desc = document.createElement("div");
desc.className = "muted description-block";
desc.innerHTML = descriptionHtml;
el.appendChild(desc);
}
if (Array.isArray(item.toHighlight) && item.toHighlight.length) {
const highlights = document.createElement("div");
highlights.className = "transcript highlight-list";
item.toHighlight.forEach((entry) => {
const html = typeof entry === "string" ? entry : entry?.html;
if (!html) return;
const source = entry && typeof entry === "object" ? entry.source : null;
const row = document.createElement("div");
row.className = "highlight-row";
if (source === "primary") {
row.classList.add("highlight-row--primary");
} else if (source === "secondary") {
row.classList.add("highlight-row--secondary");
}
const textBlock = document.createElement("div");
textBlock.className = "highlight-text";
textBlock.innerHTML = html;
row.appendChild(textBlock);
if (source) {
const indicator = document.createElement("span");
indicator.className = `highlight-source-indicator highlight-source-indicator--${source}`;
indicator.title =
source === "primary" ? "Highlight from primary transcript" : "Highlight from secondary transcript";
row.appendChild(indicator);
}
row.title = "Click to jump to this location in the transcript";
// Make highlight clickable
row.onclick = () => {
const transcriptBtn = el.querySelector(".transcript-toggle");
if (transcriptBtn && item.video_id) {
const highlightText = stripHtmlAndNormalize(html);
fetchAndDisplayTranscript(
item.video_id,
item.url,
el,
transcriptBtn,
highlightText,
item.toHighlight,
qInput.value
);
}
};
highlights.appendChild(row);
});
if (highlights.childElementCount) {
el.appendChild(highlights);
}
}
if (item.video_id) {
const transcriptBtn = document.createElement("button");
transcriptBtn.className = "transcript-toggle";
transcriptBtn.textContent = "View Full Transcript";
transcriptBtn.onclick = () => {
fetchAndDisplayTranscript(
item.video_id,
item.url,
el,
transcriptBtn,
null,
item.toHighlight,
qInput.value
);
};
el.appendChild(transcriptBtn);
}
resultsDiv.appendChild(el);
});
const pager = document.createElement("div");
pager.className = "pager";
const prev = document.createElement("button");
prev.textContent = "Prev";
prev.disabled = page <= 0;
const next = document.createElement("button");
next.textContent = "Next";
next.disabled = page + 1 >= payload.totalPages;
prev.onclick = () => runSearch(page - 1);
next.onclick = () => runSearch(page + 1);
pager.appendChild(prev);
pager.appendChild(next);
resultsDiv.appendChild(pager);
}
async function runSearch(pageOverride, pushState = true) {
const q = qInput.value.trim();
const channels = getSelectedChannels();
const year = yearSel.value;
const sort = sortSel.value;
const size = parseInt(sizeSel.value, 10) || 10;
const queryMode = queryToggle && queryToggle.checked;
let exact = !!exactToggle.checked;
let fuzzy = !!fuzzyToggle.checked;
let phrase = !!phraseToggle.checked;
const includeExternal = externalToggle ? externalToggle.checked : false;
if (queryMode) {
exact = false;
fuzzy = false;
phrase = false;
} else {
previousToggleState = {
exact,
fuzzy,
phrase,
};
settings = { ...settings, ...previousToggleState };
persistSettings();
}
const page = pageOverride != null ? pageOverride : currentPage;
currentPage = page;
if (pushState) {
updateUrl(q);
}
const params = new URLSearchParams();
params.set("q", q);
params.set("sort", sort);
params.set("size", String(size));
params.set("page", String(page));
params.set("exact", exact ? "1" : "0");
params.set("fuzzy", fuzzy ? "1" : "0");
params.set("phrase", phrase ? "1" : "0");
params.set("query_string", queryMode ? "1" : "0");
params.set("external", includeExternal ? "1" : "0");
channels.forEach((id) => params.append("channel_id", id));
if (year) params.set("year", year);
syncSettingsFromControls();
const res = await fetch(`/api/search?${params.toString()}`);
const payload = await res.json();
renderResults(payload, page);
updateFrequencyChart(q, channels, year, queryMode, { exact, fuzzy, phrase, external: includeExternal });
}
searchBtn.addEventListener("click", () => runSearch(0));
if (aboutBtn && aboutPanel) {
aboutBtn.addEventListener("click", () => {
const isHidden = aboutPanel.hasAttribute("hidden");
toggleAboutPanel(isHidden);
});
}
if (aboutCloseBtn) {
aboutCloseBtn.addEventListener("click", () => toggleAboutPanel(false));
}
qInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") runSearch(0);
});
if (channelSelect) {
channelSelect.addEventListener("change", () => {
pendingChannelSelection = channelSelect.value || "";
settings.channel = pendingChannelSelection;
persistSettings();
if (channelsReady) {
runSearch(0);
}
});
}
yearSel.addEventListener("change", () => { syncSettingsFromControls(); runSearch(0); });
sortSel.addEventListener("change", () => { syncSettingsFromControls(); runSearch(0); });
sizeSel.addEventListener("change", () => { syncSettingsFromControls(); runSearch(0); });
exactToggle.addEventListener("change", () => { rememberToggleState(); syncSettingsFromControls(); runSearch(0); });
fuzzyToggle.addEventListener("change", () => { rememberToggleState(); syncSettingsFromControls(); runSearch(0); });
phraseToggle.addEventListener("change", () => { rememberToggleState(); syncSettingsFromControls(); runSearch(0); });
if (externalToggle) {
externalToggle.addEventListener("change", () => {
pendingChannelSelection = "";
settings.external = !!externalToggle.checked;
persistSettings();
loadChannels().then(() => runSearch(0));
});
}
if (queryToggle) {
queryToggle.addEventListener("change", () => {
applyQueryMode();
syncSettingsFromControls();
runSearch(0);
});
}
window.addEventListener("popstate", () => {
qs = new URLSearchParams(window.location.search);
setFromQuery();
currentPage = 0;
runSearch(currentPage, false);
});
setFromQuery();
loadMetrics();
loadYears();
loadChannels().then(() => runSearch(currentPage));
})();
function escapeHtml(str) {
return (str || "").replace(/[&<>"']/g, (ch) => {
switch (ch) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
case "'":
return "&#39;";
default:
return ch;
}
});
}