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