424 lines
14 KiB
JavaScript
424 lines
14 KiB
JavaScript
(() => {
|
|
const queryInput = document.getElementById("vectorQuery");
|
|
const searchBtn = document.getElementById("vectorSearchBtn");
|
|
const resultsDiv = document.getElementById("vectorResults");
|
|
const metaDiv = document.getElementById("vectorMeta");
|
|
const transcriptCache = new Map();
|
|
|
|
if (!queryInput || !searchBtn || !resultsDiv || !metaDiv) {
|
|
console.error("Vector search elements missing");
|
|
return;
|
|
}
|
|
|
|
/** Utility helpers **/
|
|
const escapeHtml = (str) =>
|
|
(str || "").replace(/[&<>"']/g, (ch) => {
|
|
switch (ch) {
|
|
case "&":
|
|
return "&";
|
|
case "<":
|
|
return "<";
|
|
case ">":
|
|
return ">";
|
|
case '"':
|
|
return """;
|
|
case "'":
|
|
return "'";
|
|
default:
|
|
return ch;
|
|
}
|
|
});
|
|
|
|
const fmtDate = (value) => {
|
|
try {
|
|
return (value || "").split("T")[0];
|
|
} catch {
|
|
return value;
|
|
}
|
|
};
|
|
|
|
const fmtSimilarity = (score) => {
|
|
if (typeof score !== "number" || Number.isNaN(score)) return "";
|
|
return score.toFixed(3);
|
|
};
|
|
|
|
const getVideoStatus = (item) =>
|
|
(item && item.video_status ? String(item.video_status).toLowerCase() : "");
|
|
const isLikelyDeleted = (item) => getVideoStatus(item) === "deleted";
|
|
|
|
const 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")}`;
|
|
};
|
|
|
|
const formatSegmentTimestamp = (segment) => {
|
|
if (!segment) return "";
|
|
if (segment.timestamp) return segment.timestamp;
|
|
const fields = [
|
|
segment.start_seconds,
|
|
segment.start,
|
|
segment.offset,
|
|
segment.time,
|
|
];
|
|
for (const value of fields) {
|
|
if (value == null) continue;
|
|
const num = parseFloat(value);
|
|
if (!Number.isNaN(num)) {
|
|
return formatTimestamp(num);
|
|
}
|
|
}
|
|
return "";
|
|
};
|
|
|
|
const 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`;
|
|
};
|
|
|
|
const fetchTranscriptData = async (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;
|
|
};
|
|
|
|
const 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";
|
|
};
|
|
|
|
const 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);
|
|
};
|
|
|
|
const handleTranscriptDownload = async (item, button) => {
|
|
if (!item.video_id) return;
|
|
button.disabled = true;
|
|
try {
|
|
const transcriptData = await fetchTranscriptData(item.video_id);
|
|
if (!transcriptData) throw new Error("Transcript unavailable");
|
|
const text = buildTranscriptDownloadText(item, transcriptData);
|
|
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}.txt`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
flashButtonMessage(button, "Downloaded");
|
|
} catch (err) {
|
|
console.error("Download failed", err);
|
|
alert("Unable to download transcript right now.");
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
};
|
|
|
|
const formatMlaDate = (value) => {
|
|
if (!value) return "n.d.";
|
|
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()}`;
|
|
};
|
|
|
|
const buildMlaCitation = (item) => {
|
|
const channel = (item.channel_name || item.channel_id || "Unknown").trim();
|
|
const title = (item.title || "Untitled").trim();
|
|
const url = item.url || "";
|
|
const publishDate = formatMlaDate(item.date);
|
|
const today = formatMlaDate(new Date().toISOString().split("T")[0]);
|
|
return `${channel}. "${title}." YouTube, uploaded by ${channel}, ${publishDate}, ${url}. Accessed ${today}.`;
|
|
};
|
|
|
|
const handleCopyCitation = async (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.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(textarea);
|
|
}
|
|
flashButtonMessage(button, "Copied!");
|
|
} catch (err) {
|
|
console.error("Citation copy failed", err);
|
|
alert(citation);
|
|
}
|
|
};
|
|
|
|
/** Rendering helpers **/
|
|
const createHighlightRows = (entries) => {
|
|
if (!Array.isArray(entries) || !entries.length) return null;
|
|
const container = document.createElement("div");
|
|
container.className = "transcript highlight-list";
|
|
entries.forEach((entry) => {
|
|
if (!entry) return;
|
|
const row = document.createElement("div");
|
|
row.className = "highlight-row";
|
|
const textBlock = document.createElement("div");
|
|
textBlock.className = "highlight-text";
|
|
const html = entry.html || entry.text || entry;
|
|
textBlock.innerHTML = html || "";
|
|
row.appendChild(textBlock);
|
|
const indicator = document.createElement("span");
|
|
indicator.className = "highlight-source-indicator highlight-source-indicator--primary";
|
|
indicator.title = "Vector highlight";
|
|
row.appendChild(indicator);
|
|
container.appendChild(row);
|
|
});
|
|
return container;
|
|
};
|
|
|
|
const createActions = (item) => {
|
|
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);
|
|
|
|
const graphBtn = document.createElement("button");
|
|
graphBtn.type = "button";
|
|
graphBtn.className = "result-action-btn graph-launch-btn";
|
|
graphBtn.textContent = "Graph";
|
|
graphBtn.disabled = !item.video_id;
|
|
graphBtn.addEventListener("click", () => {
|
|
if (!item.video_id) return;
|
|
const target = `/graph?video_id=${encodeURIComponent(item.video_id)}`;
|
|
window.open(target, "_blank", "noopener");
|
|
});
|
|
actions.appendChild(graphBtn);
|
|
|
|
return actions;
|
|
};
|
|
|
|
const renderVectorResults = (payload) => {
|
|
resultsDiv.innerHTML = "";
|
|
const items = payload.items || [];
|
|
if (!items.length) {
|
|
metaDiv.textContent = "No vector matches for this prompt.";
|
|
return;
|
|
}
|
|
metaDiv.textContent = `Matches: ${items.length} (vector mode)`;
|
|
|
|
items.forEach((item) => {
|
|
const el = document.createElement("div");
|
|
el.className = "item";
|
|
const header = document.createElement("div");
|
|
header.className = "result-header";
|
|
const headerMain = document.createElement("div");
|
|
headerMain.className = "result-header-main";
|
|
const titleEl = document.createElement("strong");
|
|
titleEl.innerHTML = item.titleHtml || escapeHtml(item.title || "Untitled");
|
|
headerMain.appendChild(titleEl);
|
|
|
|
const metaLine = document.createElement("div");
|
|
metaLine.className = "muted result-meta";
|
|
const channelLabel = item.channel_name || item.channel_id || "Unknown";
|
|
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";
|
|
metaLine.appendChild(statusEl);
|
|
}
|
|
headerMain.appendChild(metaLine);
|
|
|
|
if (item.url) {
|
|
const linkLine = document.createElement("div");
|
|
linkLine.className = "muted";
|
|
const anchor = document.createElement("a");
|
|
anchor.href = item.url;
|
|
anchor.target = "_blank";
|
|
anchor.rel = "noopener";
|
|
anchor.textContent = "Open on YouTube";
|
|
linkLine.appendChild(anchor);
|
|
headerMain.appendChild(linkLine);
|
|
}
|
|
|
|
if (typeof item.distance === "number") {
|
|
const scoreLine = document.createElement("div");
|
|
scoreLine.className = "muted";
|
|
scoreLine.textContent = `Similarity score: ${fmtSimilarity(item.distance)}`;
|
|
headerMain.appendChild(scoreLine);
|
|
}
|
|
|
|
header.appendChild(headerMain);
|
|
header.appendChild(createActions(item));
|
|
el.appendChild(header);
|
|
|
|
if (item.descriptionHtml || item.description) {
|
|
const desc = document.createElement("div");
|
|
desc.className = "muted description-block";
|
|
desc.innerHTML = item.descriptionHtml || escapeHtml(item.description);
|
|
el.appendChild(desc);
|
|
}
|
|
|
|
if (item.chunkText) {
|
|
const chunkBlock = document.createElement("div");
|
|
chunkBlock.className = "vector-chunk";
|
|
if (item.chunkTimestamp && item.url) {
|
|
const tsObj =
|
|
typeof item.chunkTimestamp === "object"
|
|
? item.chunkTimestamp
|
|
: { timestamp: item.chunkTimestamp };
|
|
const ts = formatSegmentTimestamp(tsObj);
|
|
const tsLink = document.createElement("a");
|
|
const paramValue =
|
|
typeof item.chunkTimestamp === "number"
|
|
? Math.floor(item.chunkTimestamp)
|
|
: item.chunkTimestamp;
|
|
tsLink.href = `${item.url}${item.url.includes("?") ? "&" : "?"}t=${encodeURIComponent(
|
|
paramValue
|
|
)}`;
|
|
tsLink.target = "_blank";
|
|
tsLink.rel = "noopener";
|
|
tsLink.textContent = ts ? `[${ts}]` : "[timestamp]";
|
|
chunkBlock.appendChild(tsLink);
|
|
chunkBlock.appendChild(document.createTextNode(" "));
|
|
}
|
|
const chunkTextSpan = document.createElement("span");
|
|
chunkTextSpan.textContent = item.chunkText;
|
|
chunkBlock.appendChild(chunkTextSpan);
|
|
el.appendChild(chunkBlock);
|
|
}
|
|
|
|
const highlights = createHighlightRows(item.toHighlight);
|
|
if (highlights) {
|
|
el.appendChild(highlights);
|
|
}
|
|
|
|
resultsDiv.appendChild(el);
|
|
});
|
|
};
|
|
|
|
/** Search handler **/
|
|
const runVectorSearch = async () => {
|
|
const query = queryInput.value.trim();
|
|
if (!query) {
|
|
alert("Please enter a query.");
|
|
return;
|
|
}
|
|
metaDiv.textContent = "Searching vector index…";
|
|
resultsDiv.innerHTML = "";
|
|
searchBtn.disabled = true;
|
|
try {
|
|
const res = await fetch("/api/vector-search", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ query }),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`Vector search failed (${res.status})`);
|
|
}
|
|
const data = await res.json();
|
|
if (data.error) {
|
|
metaDiv.textContent = "Vector search unavailable.";
|
|
return;
|
|
}
|
|
renderVectorResults(data);
|
|
} catch (err) {
|
|
console.error(err);
|
|
metaDiv.textContent = "Vector search unavailable.";
|
|
} finally {
|
|
searchBtn.disabled = false;
|
|
}
|
|
};
|
|
|
|
searchBtn.addEventListener("click", runVectorSearch);
|
|
queryInput.addEventListener("keypress", (event) => {
|
|
if (event.key === "Enter") {
|
|
runVectorSearch();
|
|
}
|
|
});
|
|
})();
|