(() => { // 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(); let lastFocusBeforeModal = null; let pendingChannelSelection = ""; let channelsReady = false; let previousToggleState = { exact: true, fuzzy: true, phrase: true }; let currentPage = parseInt(qs.get("page") || "0", 10) || 0; function toggleAboutPanel(show) { if (!aboutPanel) return; if (show) { aboutPanel.removeAttribute("hidden"); } else { aboutPanel.setAttribute("hidden", "hidden"); } } 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 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 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 = ''; 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") || ""; yearSel.value = qs.get("year") || ""; sortSel.value = qs.get("sort") || "relevant"; sizeSel.value = qs.get("size") || "10"; pendingChannelSelection = parseChannelParam(qs); if (channelSelect) { channelSelect.value = pendingChannelSelection || ""; } exactToggle.checked = parseBoolParam("exact", true); fuzzyToggle.checked = parseBoolParam("fuzzy", true); phraseToggle.checked = parseBoolParam("phrase", true); if (externalToggle) { externalToggle.checked = parseBoolParam("external", false); } 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, }; } } 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 = ''; 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 = ""; } channelsReady = true; } catch (err) { console.error("Failed to load channels", err); channelSelect.innerHTML = ''; channelsReady = true; } } function updateUrl(q, sort, channels, year, page, size, exact, fuzzy, phrase, queryMode, includeExternal) { 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)); if (year) { next.searchParams.set("year", year); } else { next.searchParams.delete("year"); } 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"); next.searchParams.set("external", includeExternal ? "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; } 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 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 = `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; } 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 = null; if (item.video_id) { query = buildFieldClause("internal_references", [item.video_id]); } if (!query) { query = buildFieldClause("video_id", refByIds); } 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, }; } const page = pageOverride != null ? pageOverride : currentPage; currentPage = page; if (pushState) { updateUrl( q, sort, channels, year, page, size, exact, fuzzy, phrase, queryMode, includeExternal ); } 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); 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 || ""; if (channelsReady) { runSearch(0); } }); } yearSel.addEventListener("change", () => 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 (externalToggle) { externalToggle.addEventListener("change", () => { pendingChannelSelection = ""; loadChannels().then(() => 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(); loadYears(); 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; } }); }