(() => { // Theme management const themeToggle = document.getElementById("themeToggle"); const prefersDark = window.matchMedia("(prefers-color-scheme: dark)"); function getTheme() { const saved = localStorage.getItem("theme"); if (saved) return saved; return prefersDark.matches ? "dark" : "light"; } function setTheme(theme) { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("theme", theme); if (themeToggle) { themeToggle.textContent = theme === "dark" ? "☀️ Light Mode" : "🌙 Dark Mode"; } } function toggleTheme() { const current = document.documentElement.getAttribute("data-theme") || "light"; setTheme(current === "dark" ? "light" : "dark"); } // Initialize theme setTheme(getTheme()); // Listen for theme toggle if (themeToggle) { themeToggle.addEventListener("click", toggleTheme); } // Listen for system theme changes prefersDark.addEventListener("change", (e) => { if (!localStorage.getItem("theme")) { setTheme(e.matches ? "dark" : "light"); } }); let qs = new URLSearchParams(window.location.search); const qInput = document.getElementById("q"); const channelDropdown = document.getElementById("channelDropdown"); const channelSummary = document.getElementById("channelSummary"); const channelOptions = document.getElementById("channelOptions"); 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 queryToggle = document.getElementById("queryStringToggle"); const searchBtn = document.getElementById("searchBtn"); 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 channelMap = new Map(); const selectedChannels = new Set(); let pendingChannelSelection = []; let channelsReady = false; let suppressChannelChange = false; let allChannelsCheckbox = null; let previousToggleState = { exact: true, fuzzy: true, phrase: true }; let currentPage = parseInt(qs.get("page") || "0", 10) || 0; function parseBoolParam(name, defaultValue) { const raw = qs.get(name); if (raw === null) return defaultValue; const lowered = raw.toLowerCase(); return !["0", "false", "no"].includes(lowered); } function parseChannelParams(params) { const collected = []; if (!params) return collected; 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); collected.push(part); } }); }); return collected; } function getSelectedChannels() { return Array.from(selectedChannels); } function ensureAllCheckboxState() { if (allChannelsCheckbox) { allChannelsCheckbox.checked = selectedChannels.size === 0; } } function updateChannelSummary() { if (!channelSummary) return; if (!selectedChannels.size) { channelSummary.textContent = "All Channels"; return; } const names = Array.from(selectedChannels).map( (id) => channelMap.get(id) || id ); if (names.length > 1) { names.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); } let label = names.slice(0, 3).join(", "); if (names.length > 3) { label += ` +${names.length - 3} more`; } channelSummary.textContent = label; } function applyChannelSelection(ids, { silent = false } = {}) { selectedChannels.clear(); ids.forEach((id) => selectedChannels.add(id)); pendingChannelSelection = getSelectedChannels(); ensureAllCheckboxState(); if (channelOptions) { suppressChannelChange = true; const checkboxes = channelOptions.querySelectorAll( 'input[type="checkbox"][data-channel="1"]' ); checkboxes.forEach((checkbox) => { checkbox.checked = selectedChannels.has(checkbox.value); }); suppressChannelChange = false; } updateChannelSummary(); if (!silent && channelsReady) { runSearch(0); } } function setFromQuery() { qInput.value = qs.get("q") || ""; sortSel.value = qs.get("sort") || "relevant"; sizeSel.value = qs.get("size") || "10"; pendingChannelSelection = parseChannelParams(qs); applyChannelSelection(pendingChannelSelection, { silent: true }); exactToggle.checked = parseBoolParam("exact", true); fuzzyToggle.checked = parseBoolParam("fuzzy", true); phraseToggle.checked = parseBoolParam("phrase", true); queryToggle.checked = parseBoolParam("query_string", false); applyQueryMode(); rememberToggleState(); } function applyQueryMode() { if (!queryToggle) return; if (queryToggle.checked) { if (!exactToggle.disabled) { previousToggleState = { exact: exactToggle.checked, fuzzy: fuzzyToggle.checked, phrase: phraseToggle.checked, }; } 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; } } function rememberToggleState() { if (queryToggle && !queryToggle.checked) { previousToggleState = { exact: !!exactToggle.checked, fuzzy: !!fuzzyToggle.checked, phrase: !!phraseToggle.checked, }; } } if (channelOptions) { channelOptions.addEventListener("change", (event) => { const target = event.target; if (!(target instanceof HTMLInputElement) || target.type !== "checkbox") { return; } if (suppressChannelChange) { return; } if (target.dataset.all === "1") { if (!target.checked && !selectedChannels.size) { suppressChannelChange = true; target.checked = true; suppressChannelChange = false; return; } if (target.checked) { selectedChannels.clear(); pendingChannelSelection = []; suppressChannelChange = true; const others = channelOptions.querySelectorAll( 'input[type="checkbox"][data-channel="1"]' ); others.forEach((checkbox) => { checkbox.checked = false; }); suppressChannelChange = false; ensureAllCheckboxState(); updateChannelSummary(); if (channelsReady) { runSearch(0); } } return; } const id = target.value; if (!id) return; if (target.checked) { selectedChannels.add(id); } else { selectedChannels.delete(id); } pendingChannelSelection = getSelectedChannels(); ensureAllCheckboxState(); updateChannelSummary(); if (channelsReady) { runSearch(0); } }); } async function loadChannels() { if (!channelOptions) { channelsReady = true; return; } try { const res = await fetch("/api/channels"); const data = await res.json(); channelMap.clear(); channelOptions.innerHTML = ""; const listFragment = document.createDocumentFragment(); const allLabel = document.createElement("label"); allLabel.className = "channel-option"; allChannelsCheckbox = document.createElement("input"); allChannelsCheckbox.type = "checkbox"; allChannelsCheckbox.dataset.all = "1"; allChannelsCheckbox.checked = selectedChannels.size === 0; const allText = document.createElement("span"); allText.textContent = "All Channels"; allLabel.appendChild(allChannelsCheckbox); allLabel.appendChild(allText); listFragment.appendChild(allLabel); data.forEach((item) => { const label = document.createElement("label"); label.className = "channel-option"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.value = item.Id; checkbox.dataset.channel = "1"; const text = document.createElement("span"); text.textContent = `${item.Name} (${item.Count})`; label.appendChild(checkbox); label.appendChild(text); listFragment.appendChild(label); channelMap.set(item.Id, item.Name); }); channelOptions.appendChild(listFragment); if (!data.length) { const empty = document.createElement("div"); empty.textContent = "No channels available."; channelOptions.appendChild(empty); } const initialSelection = pendingChannelSelection.length ? pendingChannelSelection : Array.from(selectedChannels); applyChannelSelection(initialSelection, { silent: true }); channelsReady = true; updateChannelSummary(); } catch (err) { console.error("Failed to load channels", err); channelOptions.innerHTML = "
Failed to load channels.
"; channelsReady = true; ensureAllCheckboxState(); updateChannelSummary(); } } function updateUrl(q, sort, channels, page, size, exact, fuzzy, phrase, queryMode) { const next = new URL(window.location.href); next.searchParams.set("q", q); next.searchParams.set("sort", sort); next.searchParams.delete("channel_id"); next.searchParams.delete("channel"); channels.forEach((id) => next.searchParams.append("channel_id", id)); next.searchParams.set("page", page); next.searchParams.set("size", size); next.searchParams.set("exact", exact ? "1" : "0"); next.searchParams.set("fuzzy", fuzzy ? "1" : "0"); next.searchParams.set("phrase", phrase ? "1" : "0"); next.searchParams.set("query_string", queryMode ? "1" : "0"); 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; } 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 renderTranscriptSegment(segment, videoUrl) { const segmentDiv = document.createElement('div'); segmentDiv.className = 'transcript-segment'; segmentDiv.dataset.text = (segment.text || '').toLowerCase(); const startSeconds = segment.start_seconds || segment.start || 0; 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 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); } async function fetchAndDisplayTranscript(videoId, videoUrl, containerElement, button, highlightText = null) { 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) { 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); 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); } } 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 = `Entries: ${fmtNumber(data.totalItems)} • Channels: ${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 = "Top Channels"; 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; } 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 ? channelMap.get(key) || 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 = channelMap.get(key) || key; item.appendChild(swatch); item.appendChild(label); legend.appendChild(item); }); freqChart.appendChild(legend); } async function updateFrequencyChart(term, channels, queryMode) { 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 (queryMode) { params.set("query_string", "1"); } 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"}`; } } if (total === 0) { freqChart.innerHTML = ""; return; } renderFrequencyChart(payload.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"); const badges = []; if (item.highlightSource && item.highlightSource.primary) badges.push('primary transcript'); if (item.highlightSource && item.highlightSource.secondary) badges.push('secondary transcript'); const badgeHtml = badges.length ? `
${badges .map((b) => `${escapeHtml(b)}` ) .join('')}
` : ''; header.innerHTML = ` ${titleHtml}
${escapeHtml(item.channel_name || "")} • ${fmtDate( item.date )}
Open on YouTube
${badgeHtml} `; el.appendChild(header); if (descriptionHtml) { const desc = document.createElement("div"); desc.className = "muted"; 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 row = document.createElement("div"); row.className = "highlight-row"; row.innerHTML = html; 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); } }; 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); }; 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 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; if (queryMode) { exact = false; fuzzy = false; phrase = false; } else { previousToggleState = { exact, fuzzy, phrase, }; } const page = pageOverride != null ? pageOverride : currentPage; currentPage = page; if (pushState) { updateUrl(q, sort, channels, page, size, exact, fuzzy, phrase, queryMode); } 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"); channels.forEach((id) => params.append("channel_id", id)); const res = await fetch(`/api/search?${params.toString()}`); const payload = await res.json(); renderResults(payload, page); updateFrequencyChart(q, channels, queryMode); } searchBtn.addEventListener("click", () => runSearch(0)); qInput.addEventListener("keypress", (e) => { if (e.key === "Enter") runSearch(0); }); sortSel.addEventListener("change", () => runSearch(0)); sizeSel.addEventListener("change", () => runSearch(0)); exactToggle.addEventListener("change", () => { rememberToggleState(); runSearch(0); }); fuzzyToggle.addEventListener("change", () => { rememberToggleState(); runSearch(0); }); phraseToggle.addEventListener("change", () => { rememberToggleState(); runSearch(0); }); if (queryToggle) { queryToggle.addEventListener("change", () => { applyQueryMode(); runSearch(0); }); } window.addEventListener("popstate", () => { qs = new URLSearchParams(window.location.search); setFromQuery(); currentPage = parseInt(qs.get("page") || "0", 10) || 0; runSearch(currentPage, false); }); setFromQuery(); loadMetrics(); loadChannels().then(() => runSearch(currentPage)); })(); function escapeHtml(str) { return (str || "").replace(/[&<>"']/g, (ch) => { switch (ch) { case "&": return "&"; case "<": return "<"; case ">": return ">"; case '"': return """; case "'": return "'"; default: return ch; } }); }