Add graph and vector search features
This commit is contained in:
691
static/app.js
691
static/app.js
@@ -32,9 +32,7 @@
|
||||
|
||||
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 channelSelect = document.getElementById("channel");
|
||||
const yearSel = document.getElementById("year");
|
||||
const sortSel = document.getElementById("sort");
|
||||
const sizeSel = document.getElementById("size");
|
||||
@@ -43,6 +41,9 @@
|
||||
const phraseToggle = document.getElementById("phraseToggle");
|
||||
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");
|
||||
@@ -50,17 +51,27 @@
|
||||
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 selectedChannels = new Set();
|
||||
let pendingChannelSelection = [];
|
||||
const transcriptCache = new Map();
|
||||
let lastFocusBeforeModal = null;
|
||||
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 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;
|
||||
@@ -68,9 +79,8 @@
|
||||
return !["0", "false", "no"].includes(lowered);
|
||||
}
|
||||
|
||||
function parseChannelParams(params) {
|
||||
const collected = [];
|
||||
if (!params) return collected;
|
||||
function parseChannelParam(params) {
|
||||
if (!params) return "";
|
||||
const seen = new Set();
|
||||
const rawValues = params.getAll("channel_id");
|
||||
const legacy = params.get("channel");
|
||||
@@ -84,61 +94,17 @@
|
||||
.forEach((part) => {
|
||||
if (!seen.has(part)) {
|
||||
seen.add(part);
|
||||
collected.push(part);
|
||||
}
|
||||
});
|
||||
});
|
||||
return collected;
|
||||
const first = Array.from(seen)[0];
|
||||
return first || "";
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
if (!channelSelect) return [];
|
||||
const value = channelSelect.value;
|
||||
return value ? [value] : [];
|
||||
}
|
||||
|
||||
async function loadYears() {
|
||||
@@ -166,8 +132,10 @@
|
||||
yearSel.value = qs.get("year") || "";
|
||||
sortSel.value = qs.get("sort") || "relevant";
|
||||
sizeSel.value = qs.get("size") || "10";
|
||||
pendingChannelSelection = parseChannelParams(qs);
|
||||
applyChannelSelection(pendingChannelSelection, { silent: true });
|
||||
pendingChannelSelection = parseChannelParam(qs);
|
||||
if (channelSelect) {
|
||||
channelSelect.value = pendingChannelSelection || "";
|
||||
}
|
||||
exactToggle.checked = parseBoolParam("exact", true);
|
||||
fuzzyToggle.checked = parseBoolParam("fuzzy", true);
|
||||
phraseToggle.checked = parseBoolParam("phrase", true);
|
||||
@@ -212,6 +180,76 @@
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -242,60 +280,8 @@
|
||||
return `${field}:(${escaped.join(" OR ")})`;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!channelSelect) {
|
||||
channelsReady = true;
|
||||
return;
|
||||
}
|
||||
@@ -303,57 +289,27 @@
|
||||
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);
|
||||
channelSelect.innerHTML = '<option value="">All Channels</option>';
|
||||
|
||||
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);
|
||||
const option = document.createElement("option");
|
||||
option.value = item.Id;
|
||||
option.textContent = `${item.Name} (${item.Count})`;
|
||||
channelSelect.appendChild(option);
|
||||
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);
|
||||
if (pendingChannelSelection && channelMap.has(pendingChannelSelection)) {
|
||||
channelSelect.value = pendingChannelSelection;
|
||||
} else {
|
||||
channelSelect.value = "";
|
||||
}
|
||||
|
||||
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>";
|
||||
channelSelect.innerHTML = '<option value="">All Channels</option>';
|
||||
channelsReady = true;
|
||||
ensureAllCheckboxState();
|
||||
updateChannelSummary();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +347,188 @@
|
||||
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);
|
||||
@@ -621,7 +759,65 @@
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function fetchAndDisplayTranscript(videoId, videoUrl, containerElement, button, highlightText = null) {
|
||||
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();
|
||||
@@ -631,6 +827,7 @@
|
||||
|
||||
// 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);
|
||||
@@ -728,6 +925,7 @@
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
highlightTranscriptMatches(transcriptDiv, allHighlights, searchQuery);
|
||||
} catch (err) {
|
||||
console.error('Error fetching transcript:', err);
|
||||
button.textContent = 'View Full Transcript';
|
||||
@@ -797,7 +995,8 @@ function clearFrequency(message) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderFrequencyChart(buckets, channelTotals) {
|
||||
|
||||
function renderFrequencyChart(buckets, channelTotals) {
|
||||
if (!freqChart || typeof d3 === "undefined") {
|
||||
return;
|
||||
}
|
||||
@@ -807,6 +1006,26 @@ function renderFrequencyChart(buckets, channelTotals) {
|
||||
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)
|
||||
@@ -929,7 +1148,7 @@ function renderFrequencyChart(buckets, channelTotals) {
|
||||
.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 || '';
|
||||
const label = key ? getChannelLabel(key) : key || '';
|
||||
return `${dateKeyFormat(d.data.date)}: ${d[1] - d[0]}${label ? " (" + label + ")" : ''}`;
|
||||
});
|
||||
|
||||
@@ -942,7 +1161,7 @@ function renderFrequencyChart(buckets, channelTotals) {
|
||||
swatch.className = "freq-legend-swatch";
|
||||
swatch.style.backgroundColor = color(key);
|
||||
const label = document.createElement("span");
|
||||
label.textContent = channelMap.get(key) || key;
|
||||
label.textContent = getChannelLabel(key) || key;
|
||||
item.appendChild(swatch);
|
||||
item.appendChild(label);
|
||||
legend.appendChild(item);
|
||||
@@ -1027,12 +1246,15 @@ async function updateFrequencyChart(term, channels, year, queryMode, toggles = {
|
||||
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.highlightSource && item.highlightSource.primary) {
|
||||
badgeDefs.push({ label: "primary transcript" });
|
||||
badgeDefs.push({ label: "primary transcript", badgeType: "transcript-primary" });
|
||||
}
|
||||
if (item.highlightSource && item.highlightSource.secondary) {
|
||||
badgeDefs.push({ label: "secondary transcript" });
|
||||
badgeDefs.push({ label: "secondary transcript", badgeType: "transcript-secondary" });
|
||||
}
|
||||
|
||||
// Add reference count badges
|
||||
@@ -1068,13 +1290,47 @@ async function updateFrequencyChart(term, channels, year, queryMode, toggles = {
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
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";
|
||||
@@ -1086,6 +1342,9 @@ async function updateFrequencyChart(term, channels, year, queryMode, toggles = {
|
||||
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");
|
||||
@@ -1110,7 +1369,45 @@ async function updateFrequencyChart(term, channels, year, queryMode, toggles = {
|
||||
badgeRow.appendChild(badgeEl);
|
||||
});
|
||||
if (badgeRow.childElementCount) {
|
||||
header.appendChild(badgeRow);
|
||||
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);
|
||||
@@ -1128,9 +1425,25 @@ async function updateFrequencyChart(term, channels, year, queryMode, toggles = {
|
||||
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";
|
||||
row.innerHTML = html;
|
||||
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
|
||||
@@ -1138,7 +1451,15 @@ async function updateFrequencyChart(term, channels, year, queryMode, toggles = {
|
||||
const transcriptBtn = el.querySelector(".transcript-toggle");
|
||||
if (transcriptBtn && item.video_id) {
|
||||
const highlightText = stripHtmlAndNormalize(html);
|
||||
fetchAndDisplayTranscript(item.video_id, item.url, el, transcriptBtn, highlightText);
|
||||
fetchAndDisplayTranscript(
|
||||
item.video_id,
|
||||
item.url,
|
||||
el,
|
||||
transcriptBtn,
|
||||
highlightText,
|
||||
item.toHighlight,
|
||||
qInput.value
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1154,7 +1475,15 @@ async function updateFrequencyChart(term, channels, year, queryMode, toggles = {
|
||||
transcriptBtn.className = "transcript-toggle";
|
||||
transcriptBtn.textContent = "View Full Transcript";
|
||||
transcriptBtn.onclick = () => {
|
||||
fetchAndDisplayTranscript(item.video_id, item.url, el, transcriptBtn);
|
||||
fetchAndDisplayTranscript(
|
||||
item.video_id,
|
||||
item.url,
|
||||
el,
|
||||
transcriptBtn,
|
||||
null,
|
||||
item.toHighlight,
|
||||
qInput.value
|
||||
);
|
||||
};
|
||||
el.appendChild(transcriptBtn);
|
||||
}
|
||||
@@ -1223,10 +1552,28 @@ async function updateFrequencyChart(term, channels, year, queryMode, toggles = {
|
||||
updateFrequencyChart(q, channels, year, queryMode, { exact, fuzzy, phrase });
|
||||
}
|
||||
|
||||
searchBtn.addEventListener("click", () => runSearch(0));
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user