knight d8d2c5e34c Fix results overflow and add debug logging for reference badges
CSS Changes:
- Added max-width and overflow handling to .badge-row
- Added word-wrap and overflow protection to .item
- Added overflow-x: hidden to .window-body
- Badges now use white-space: nowrap to prevent text wrapping
- Item titles now break words properly with word-break

JavaScript Changes:
- Added console.log debugging for reference counts
- Logs show whether fields are present and their values
- Helps diagnose why badges aren't appearing

This should fix the overflow issue and help debug badge visibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 11:18:17 -05:00

1181 lines
38 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 channelDropdown = document.getElementById("channelDropdown");
const channelSummary = document.getElementById("channelSummary");
const channelOptions = document.getElementById("channelOptions");
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 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);
}
}
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") || "";
yearSel.value = qs.get("year") || "";
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 = "<div>Failed to load channels.</div>";
channelsReady = true;
ensureAllCheckboxState();
updateChannelSummary();
}
}
function updateUrl(q, sort, channels, year, 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));
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");
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 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);
}
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);
// 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);
}
} 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;
}
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, year, 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 (year) {
params.set("year", year);
}
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');
// Add reference count badges
const refByCount = item.referenced_by_count || 0;
const refToCount = item.internal_references_count || 0;
// Debug: log reference counts for first item
if (badges.length === 0 || badges.length <= 2) {
console.log('Reference counts:', {
title: item.title,
referenced_by_count: refByCount,
internal_references_count: refToCount,
hasReferencedBy: 'referenced_by_count' in item,
hasInternalRefs: 'internal_references_count' in item
});
}
if (refByCount > 0) badges.push(`${refByCount} backlink${refByCount !== 1 ? 's' : ''}`);
if (refToCount > 0) badges.push(`${refToCount} reference${refToCount !== 1 ? 's' : ''}`);
const badgeHtml = badges.length
? `<div class="badge-row">${badges
.map((b) => `<span class="badge">${escapeHtml(b)}</span>` )
.join('')}</div>`
: '';
header.innerHTML = `
<strong>${titleHtml}</strong>
<div class="muted">${escapeHtml(item.channel_name || "")}${fmtDate(
item.date
)}</div>
<div class="muted"><a href="${item.url}" target="_blank" rel="noopener">Open on YouTube</a></div>
${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 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;
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);
}
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));
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);
}
searchBtn.addEventListener("click", () => runSearch(0));
qInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") 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 (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 "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
case "'":
return "&#39;";
default:
return ch;
}
});
}