Initial commit

This commit is contained in:
2025-11-02 01:14:36 -04:00
commit fcdc6ecb9b
13 changed files with 2883 additions and 0 deletions

733
static/app.js Normal file
View File

@@ -0,0 +1,733 @@
(() => {
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 sortSel = document.getElementById("sort");
const sizeSel = document.getElementById("size");
const exactToggle = document.getElementById("exactToggle");
const fuzzyToggle = document.getElementById("fuzzyToggle");
const phraseToggle = document.getElementById("phraseToggle");
const queryToggle = document.getElementById("queryStringToggle");
const searchBtn = document.getElementById("searchBtn");
const resultsDiv = document.getElementById("results");
const metaDiv = document.getElementById("meta");
const metricsContainer = document.getElementById("metrics");
const metricsStatus = document.getElementById("metricsStatus");
const metricsContent = document.getElementById("metricsContent");
const freqSummary = document.getElementById("frequencySummary");
const freqChart = document.getElementById("frequencyChart");
const channelMap = new Map();
const selectedChannels = new Set();
let pendingChannelSelection = [];
let channelsReady = false;
let suppressChannelChange = false;
let allChannelsCheckbox = null;
let previousToggleState = { exact: true, fuzzy: true, phrase: true };
let currentPage =
parseInt(qs.get("page") || "0", 10) ||
0;
function parseBoolParam(name, defaultValue) {
const raw = qs.get(name);
if (raw === null) return defaultValue;
const lowered = raw.toLowerCase();
return !["0", "false", "no"].includes(lowered);
}
function parseChannelParams(params) {
const collected = [];
if (!params) return collected;
const seen = new Set();
const rawValues = params.getAll("channel_id");
const legacy = params.get("channel");
if (legacy) rawValues.push(legacy);
rawValues.forEach((value) => {
if (value == null) return;
String(value)
.split(",")
.map((part) => part.trim())
.filter((part) => part && part.toLowerCase() !== "all")
.forEach((part) => {
if (!seen.has(part)) {
seen.add(part);
collected.push(part);
}
});
});
return collected;
}
function getSelectedChannels() {
return Array.from(selectedChannels);
}
function ensureAllCheckboxState() {
if (allChannelsCheckbox) {
allChannelsCheckbox.checked = selectedChannels.size === 0;
}
}
function updateChannelSummary() {
if (!channelSummary) return;
if (!selectedChannels.size) {
channelSummary.textContent = "All Channels";
return;
}
const names = Array.from(selectedChannels).map(
(id) => channelMap.get(id) || id
);
if (names.length > 1) {
names.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
}
let label = names.slice(0, 3).join(", ");
if (names.length > 3) {
label += ` +${names.length - 3} more`;
}
channelSummary.textContent = label;
}
function applyChannelSelection(ids, { silent = false } = {}) {
selectedChannels.clear();
ids.forEach((id) => selectedChannels.add(id));
pendingChannelSelection = getSelectedChannels();
ensureAllCheckboxState();
if (channelOptions) {
suppressChannelChange = true;
const checkboxes = channelOptions.querySelectorAll(
'input[type="checkbox"][data-channel="1"]'
);
checkboxes.forEach((checkbox) => {
checkbox.checked = selectedChannels.has(checkbox.value);
});
suppressChannelChange = false;
}
updateChannelSummary();
if (!silent && channelsReady) {
runSearch(0);
}
}
function setFromQuery() {
qInput.value = qs.get("q") || "";
sortSel.value = qs.get("sort") || "relevant";
sizeSel.value = qs.get("size") || "10";
pendingChannelSelection = parseChannelParams(qs);
applyChannelSelection(pendingChannelSelection, { silent: true });
exactToggle.checked = parseBoolParam("exact", true);
fuzzyToggle.checked = parseBoolParam("fuzzy", true);
phraseToggle.checked = parseBoolParam("phrase", true);
queryToggle.checked = parseBoolParam("query_string", false);
applyQueryMode();
rememberToggleState();
}
function applyQueryMode() {
if (!queryToggle) return;
if (queryToggle.checked) {
if (!exactToggle.disabled) {
previousToggleState = {
exact: exactToggle.checked,
fuzzy: fuzzyToggle.checked,
phrase: phraseToggle.checked,
};
}
exactToggle.checked = false;
fuzzyToggle.checked = false;
phraseToggle.checked = false;
exactToggle.disabled = true;
fuzzyToggle.disabled = true;
phraseToggle.disabled = true;
} else {
exactToggle.disabled = false;
fuzzyToggle.disabled = false;
phraseToggle.disabled = false;
exactToggle.checked = previousToggleState.exact;
fuzzyToggle.checked = previousToggleState.fuzzy;
phraseToggle.checked = previousToggleState.phrase;
}
}
function rememberToggleState() {
if (queryToggle && !queryToggle.checked) {
previousToggleState = {
exact: !!exactToggle.checked,
fuzzy: !!fuzzyToggle.checked,
phrase: !!phraseToggle.checked,
};
}
}
if (channelOptions) {
channelOptions.addEventListener("change", (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement) || target.type !== "checkbox") {
return;
}
if (suppressChannelChange) {
return;
}
if (target.dataset.all === "1") {
if (!target.checked && !selectedChannels.size) {
suppressChannelChange = true;
target.checked = true;
suppressChannelChange = false;
return;
}
if (target.checked) {
selectedChannels.clear();
pendingChannelSelection = [];
suppressChannelChange = true;
const others = channelOptions.querySelectorAll(
'input[type="checkbox"][data-channel="1"]'
);
others.forEach((checkbox) => {
checkbox.checked = false;
});
suppressChannelChange = false;
ensureAllCheckboxState();
updateChannelSummary();
if (channelsReady) {
runSearch(0);
}
}
return;
}
const id = target.value;
if (!id) return;
if (target.checked) {
selectedChannels.add(id);
} else {
selectedChannels.delete(id);
}
pendingChannelSelection = getSelectedChannels();
ensureAllCheckboxState();
updateChannelSummary();
if (channelsReady) {
runSearch(0);
}
});
}
async function loadChannels() {
if (!channelOptions) {
channelsReady = true;
return;
}
try {
const res = await fetch("/api/channels");
const data = await res.json();
channelMap.clear();
channelOptions.innerHTML = "";
const listFragment = document.createDocumentFragment();
const allLabel = document.createElement("label");
allLabel.className = "channel-option";
allChannelsCheckbox = document.createElement("input");
allChannelsCheckbox.type = "checkbox";
allChannelsCheckbox.dataset.all = "1";
allChannelsCheckbox.checked = selectedChannels.size === 0;
const allText = document.createElement("span");
allText.textContent = "All Channels";
allLabel.appendChild(allChannelsCheckbox);
allLabel.appendChild(allText);
listFragment.appendChild(allLabel);
data.forEach((item) => {
const label = document.createElement("label");
label.className = "channel-option";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = item.Id;
checkbox.dataset.channel = "1";
const text = document.createElement("span");
text.textContent = `${item.Name} (${item.Count})`;
label.appendChild(checkbox);
label.appendChild(text);
listFragment.appendChild(label);
channelMap.set(item.Id, item.Name);
});
channelOptions.appendChild(listFragment);
if (!data.length) {
const empty = document.createElement("div");
empty.textContent = "No channels available.";
channelOptions.appendChild(empty);
}
const initialSelection = pendingChannelSelection.length
? pendingChannelSelection
: Array.from(selectedChannels);
applyChannelSelection(initialSelection, { silent: true });
channelsReady = true;
updateChannelSummary();
} catch (err) {
console.error("Failed to load channels", err);
channelOptions.innerHTML = "<div>Failed to load channels.</div>";
channelsReady = true;
ensureAllCheckboxState();
updateChannelSummary();
}
}
function updateUrl(q, sort, channels, page, size, exact, fuzzy, phrase, queryMode) {
const next = new URL(window.location.href);
next.searchParams.set("q", q);
next.searchParams.set("sort", sort);
next.searchParams.delete("channel_id");
next.searchParams.delete("channel");
channels.forEach((id) => next.searchParams.append("channel_id", id));
next.searchParams.set("page", page);
next.searchParams.set("size", size);
next.searchParams.set("exact", exact ? "1" : "0");
next.searchParams.set("fuzzy", fuzzy ? "1" : "0");
next.searchParams.set("phrase", phrase ? "1" : "0");
next.searchParams.set("query_string", queryMode ? "1" : "0");
history.pushState({}, "", next.toString());
}
function fmtDate(value) {
try {
return (value || "").split("T")[0];
} catch {
return value;
}
}
function fmtNumber(n) {
if (typeof n === "number") return n.toLocaleString();
return n;
}
// Transcript viewer functionality removed.
function renderMetrics(data) {
if (!metricsContent) return;
metricsContent.innerHTML = "";
if (!data) return;
if (metricsStatus) {
metricsStatus.textContent = "";
}
const summary = document.createElement("div");
summary.innerHTML = `<strong>Entries:</strong> ${fmtNumber(data.totalItems)} • <strong>Channels:</strong> ${fmtNumber(data.totalChannels)}`;
metricsContent.appendChild(summary);
if (Array.isArray(data.itemsPerChannel) && data.itemsPerChannel.length) {
const top = data.itemsPerChannel.slice(0, 5);
const channelHeader = document.createElement("div");
channelHeader.style.marginTop = "8px";
channelHeader.innerHTML = "<strong>Top Channels</strong>";
metricsContent.appendChild(channelHeader);
const channelList = document.createElement("div");
channelList.className = "muted";
top.forEach((entry) => {
const row = document.createElement("div");
row.textContent = `${entry.label}: ${fmtNumber(entry.count)}`;
channelList.appendChild(row);
});
metricsContent.appendChild(channelList);
}
}
async function loadMetrics() {
if (!metricsContainer) return;
metricsContainer.dataset.loading = "1";
if (!metricsContainer.dataset.loaded && metricsStatus) {
metricsStatus.textContent = "Loading metrics…";
}
try {
const res = await fetch("/api/metrics");
const data = await res.json();
renderMetrics(data);
metricsContainer.dataset.loaded = "1";
} catch (err) {
console.error("Failed to load metrics", err);
if (!metricsContainer.dataset.loaded && metricsStatus) {
metricsStatus.textContent = "Metrics unavailable.";
}
} finally {
delete metricsContainer.dataset.loading;
}
}
function clearFrequency(message) {
if (freqSummary) {
freqSummary.textContent = message || "";
}
if (freqChart) {
freqChart.innerHTML = "";
}
}
function renderFrequencyChart(buckets, channelTotals) {
if (!freqChart || typeof d3 === "undefined") {
return;
}
freqChart.innerHTML = "";
if (!buckets.length) {
clearFrequency("No matches for this query.");
return;
}
let channelsOrder =
(channelTotals && channelTotals.length
? channelTotals.map((entry) => entry.id)
: []) || [];
if (!channelsOrder.length) {
const unique = new Set();
buckets.forEach((bucket) => {
(bucket.channels || []).forEach((entry) => unique.add(entry.id));
});
channelsOrder = Array.from(unique);
}
channelsOrder = channelsOrder.slice(0, 6);
if (!channelsOrder.length) {
clearFrequency("No matches for this query.");
return;
}
const dateKeyFormat = d3.timeFormat("%Y-%m-%d");
const parsed = buckets
.map((bucket) => {
const parsedDate = d3.isoParse(bucket.date) || new Date(bucket.date);
if (!(parsedDate instanceof Date) || Number.isNaN(parsedDate.valueOf())) {
return null;
}
const counts = {};
(bucket.channels || []).forEach((entry) => {
if (channelsOrder.includes(entry.id)) {
counts[entry.id] = entry.count || 0;
}
});
return {
date: parsedDate,
dateKey: dateKeyFormat(parsedDate),
counts,
};
})
.filter(Boolean);
if (!parsed.length) {
clearFrequency("Timeline unavailable.");
return;
}
const margin = { top: 12, right: 12, bottom: 52, left: 56 };
const fullWidth = freqChart.clientWidth || 360;
const fullHeight = 220;
const width = fullWidth - margin.left - margin.right;
const height = fullHeight - margin.top - margin.bottom;
const svg = d3
.select(freqChart)
.append("svg")
.attr("width", fullWidth)
.attr("height", fullHeight);
const g = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const x = d3
.scaleBand()
.domain(parsed.map((entry) => entry.dateKey))
.range([0, width])
.padding(0.25);
const yMax = d3.max(parsed, (entry) =>
d3.sum(channelsOrder, (key) => entry.counts[key] || 0)
);
const y = d3
.scaleLinear()
.domain([0, yMax || 0])
.nice()
.range([height, 0]);
const tickValues =
parsed.length <= 6
? parsed.map((entry) => entry.dateKey)
: parsed
.filter((_, index, arr) => index % Math.ceil(arr.length / 6) === 0)
.map((entry) => entry.dateKey);
const xAxis = d3.axisBottom(x).tickValues(tickValues);
const yAxis = d3.axisLeft(y).ticks(5);
g.append("g")
.attr("class", "axis")
.attr("transform", `translate(0,${height})`)
.call(xAxis)
.selectAll("text")
.attr("text-anchor", "end")
.attr("transform", "rotate(-35)")
.attr("dx", "-0.8em")
.attr("dy", "0.15em");
g.append("g").attr("class", "axis").call(yAxis);
const stack = d3.stack().keys(channelsOrder).value((entry, key) => entry.counts[key] || 0);
const stacked = stack(parsed);
const color = d3.scaleOrdinal(channelsOrder, d3.schemeTableau10);
const layers = g
.selectAll(".freq-layer")
.data(stacked)
.enter()
.append("g")
.attr("class", "freq-layer")
.attr("fill", (d) => color(d.key));
layers
.selectAll("rect")
.data((d) => d)
.enter()
.append("rect")
.attr("x", (d) => x(d.data.dateKey))
.attr("width", x.bandwidth())
.attr("y", (d) => y(d[1]))
.attr("height", (d) => y(d[0]) - y(d[1]))
.append("title")
.text(function (d) {
const group = this.parentNode ? this.parentNode.parentNode : null;
const key = group ? d3.select(group).datum().key : undefined;
const label = key ? channelMap.get(key) || key : key || '';
return `${dateKeyFormat(d.data.date)}: ${d[1] - d[0]}${label ? " (" + label + ")" : ''}`;
});
const legend = document.createElement("div");
legend.className = "freq-legend";
channelsOrder.forEach((key) => {
const item = document.createElement("div");
item.className = "freq-legend-item";
const swatch = document.createElement("span");
swatch.className = "freq-legend-swatch";
swatch.style.backgroundColor = color(key);
const label = document.createElement("span");
label.textContent = channelMap.get(key) || key;
item.appendChild(swatch);
item.appendChild(label);
legend.appendChild(item);
});
freqChart.appendChild(legend);
}
async function updateFrequencyChart(term, channels, queryMode) {
if (!freqChart || typeof d3 === "undefined") {
return;
}
let trimmed = term.trim();
if (!trimmed) {
if (queryMode) {
trimmed = "*";
} else {
clearFrequency("Enter a query to see timeline.");
return;
}
}
const params = new URLSearchParams();
params.set("term", trimmed);
params.set("interval", "month");
(channels || []).forEach((id) => params.append("channel_id", id));
if (queryMode) {
params.set("query_string", "1");
}
clearFrequency("Loading timeline…");
try {
const res = await fetch(`/api/frequency?${params.toString()}`);
if (!res.ok) {
throw new Error(`Request failed with status ${res.status}`);
}
const payload = await res.json();
const total = payload.totalResults || 0;
if (freqSummary) {
if (total === 0) {
freqSummary.textContent = "No matches for this query.";
} else if (queryMode) {
freqSummary.textContent = `Matches: ${total.toLocaleString()} • Interval: ${payload.interval || "month"} (query-string)`;
} else {
freqSummary.textContent = `Matches: ${total.toLocaleString()} • Interval: ${payload.interval || "month"}`;
}
}
if (total === 0) {
freqChart.innerHTML = "";
return;
}
renderFrequencyChart(payload.buckets || [], payload.channels || []);
} catch (err) {
console.error(err);
clearFrequency("Timeline unavailable.");
}
}
function renderResults(payload, page) {
resultsDiv.innerHTML = "";
metaDiv.textContent = `Total: ${payload.totalResults} • Page ${
page + 1
} of ${payload.totalPages}`;
(payload.items || []).forEach((item) => {
const el = document.createElement("div");
el.className = "item";
const titleHtml =
item.titleHtml || escapeHtml(item.title || "Untitled");
const descriptionHtml =
item.descriptionHtml || escapeHtml(item.description || "");
const header = document.createElement("div");
const badges = [];
if (item.highlightSource && item.highlightSource.primary) badges.push('primary transcript');
if (item.highlightSource && item.highlightSource.secondary) badges.push('secondary transcript');
const badgeHtml = badges.length
? `<div class="badge-row">${badges
.map((b) => `<span class="badge">${escapeHtml(b)}</span>` )
.join('')}</div>`
: '';
header.innerHTML = `
<strong>${titleHtml}</strong>
<div class="muted">${escapeHtml(item.channel_name || "")}${fmtDate(
item.date
)}</div>
<div class="muted"><a href="${item.url}" target="_blank" rel="noopener">Open on YouTube</a></div>
${badgeHtml}
`;
el.appendChild(header);
if (descriptionHtml) {
const desc = document.createElement("div");
desc.className = "muted";
desc.innerHTML = descriptionHtml;
el.appendChild(desc);
}
if (Array.isArray(item.toHighlight) && item.toHighlight.length) {
const highlights = document.createElement("div");
highlights.className = "transcript highlight-list";
item.toHighlight.forEach((entry) => {
const html = typeof entry === "string" ? entry : entry?.html;
if (!html) return;
const row = document.createElement("div");
row.className = "highlight-row";
row.innerHTML = html;
highlights.appendChild(row);
});
if (highlights.childElementCount) {
el.appendChild(highlights);
}
}
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 sort = sortSel.value;
const size = parseInt(sizeSel.value, 10) || 10;
const queryMode = queryToggle && queryToggle.checked;
let exact = !!exactToggle.checked;
let fuzzy = !!fuzzyToggle.checked;
let phrase = !!phraseToggle.checked;
if (queryMode) {
exact = false;
fuzzy = false;
phrase = false;
} else {
previousToggleState = {
exact,
fuzzy,
phrase,
};
}
const page = pageOverride != null ? pageOverride : currentPage;
currentPage = page;
if (pushState) {
updateUrl(q, sort, channels, page, size, exact, fuzzy, phrase, queryMode);
}
const params = new URLSearchParams();
params.set("q", q);
params.set("sort", sort);
params.set("size", String(size));
params.set("page", String(page));
params.set("exact", exact ? "1" : "0");
params.set("fuzzy", fuzzy ? "1" : "0");
params.set("phrase", phrase ? "1" : "0");
params.set("query_string", queryMode ? "1" : "0");
channels.forEach((id) => params.append("channel_id", id));
const res = await fetch(`/api/search?${params.toString()}`);
const payload = await res.json();
renderResults(payload, page);
updateFrequencyChart(q, channels, queryMode);
}
searchBtn.addEventListener("click", () => runSearch(0));
qInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") runSearch(0);
});
sortSel.addEventListener("change", () => runSearch(0));
sizeSel.addEventListener("change", () => runSearch(0));
exactToggle.addEventListener("change", () => { rememberToggleState(); runSearch(0); });
fuzzyToggle.addEventListener("change", () => { rememberToggleState(); runSearch(0); });
phraseToggle.addEventListener("change", () => { rememberToggleState(); runSearch(0); });
if (queryToggle) {
queryToggle.addEventListener("change", () => { applyQueryMode(); runSearch(0); });
}
window.addEventListener("popstate", () => {
qs = new URLSearchParams(window.location.search);
setFromQuery();
currentPage = parseInt(qs.get("page") || "0", 10) || 0;
runSearch(currentPage, false);
});
setFromQuery();
loadMetrics();
loadChannels().then(() => runSearch(currentPage));
})();
function escapeHtml(str) {
return (str || "").replace(/[&<>"']/g, (ch) => {
switch (ch) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
case "'":
return "&#39;";
default:
return ch;
}
});
}

68
static/frequency.html Normal file
View File

@@ -0,0 +1,68 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Term Frequency Explorer</title>
<link rel="stylesheet" href="/static/style.css" />
<style>
#chart {
margin-top: 24px;
}
svg {
max-width: 100%;
}
.axis path,
.axis line {
stroke: #ccc;
}
.line {
fill: none;
stroke: #0b6efd;
stroke-width: 2px;
}
.dot {
fill: #0b6efd;
stroke: white;
stroke-width: 1px;
}
.controls label {
display: flex;
align-items: center;
gap: 6px;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
</head>
<body>
<header>
<h1>Term Frequency Explorer</h1>
<p class="muted">
Pick a term to see how often it appears over time. <a href="/">Back to search</a>
</p>
</header>
<section class="controls">
<input id="term" type="text" placeholder="Term (e.g. meaning)" size="28" />
<select id="channel">
<option value="all">All Channels</option>
</select>
<select id="interval">
<option value="month">Per Month</option>
<option value="week">Per Week</option>
<option value="day">Per Day</option>
<option value="quarter">Per Quarter</option>
<option value="year">Per Year</option>
</select>
<input id="start" type="date" />
<input id="end" type="date" />
<button id="runBtn">Run</button>
</section>
<section id="summary" class="muted"></section>
<section id="chart"></section>
<script src="/static/frequency.js"></script>
</body>
</html>

222
static/frequency.js Normal file
View File

@@ -0,0 +1,222 @@
(() => {
let qs = new URLSearchParams(window.location.search);
const termInput = document.getElementById("term");
const channelSel = document.getElementById("channel");
const intervalSel = document.getElementById("interval");
const startInput = document.getElementById("start");
const endInput = document.getElementById("end");
const runBtn = document.getElementById("runBtn");
const summaryDiv = document.getElementById("summary");
const chartDiv = document.getElementById("chart");
function parseParams() {
return {
term: qs.get("term") || "",
channel: qs.get("channel_id") || "all",
interval: qs.get("interval") || "month",
start: qs.get("start") || "",
end: qs.get("end") || "",
};
}
function setFormFromParams() {
const params = parseParams();
termInput.value = params.term;
intervalSel.value = params.interval;
startInput.value = params.start;
endInput.value = params.end;
return params;
}
function updateUrl(params) {
const url = new URL(window.location.href);
url.searchParams.set("term", params.term);
url.searchParams.set("channel_id", params.channel);
url.searchParams.set("interval", params.interval);
if (params.start) url.searchParams.set("start", params.start);
else url.searchParams.delete("start");
if (params.end) url.searchParams.set("end", params.end);
else url.searchParams.delete("end");
history.pushState({}, "", url.toString());
qs = new URLSearchParams(url.search);
}
async function loadChannels(initialValue) {
try {
const res = await fetch("/api/channels");
const data = await res.json();
data.forEach((item) => {
const opt = document.createElement("option");
opt.value = item.Id;
opt.textContent = `${item.Name} (${item.Count})`;
channelSel.appendChild(opt);
});
} catch (err) {
console.error("Failed to load channels", err);
}
channelSel.value = initialValue || "all";
}
function drawChart(data) {
chartDiv.innerHTML = "";
if (!data.length) {
const msg = document.createElement("div");
msg.className = "muted";
msg.textContent = "No matching documents for this term.";
chartDiv.appendChild(msg);
return;
}
const parsed = data
.map((d) => ({
date: d3.isoParse(d.date) || new Date(d.date),
value: d.count,
}))
.filter((d) => d.date instanceof Date && !Number.isNaN(d.date.valueOf()));
if (!parsed.length) {
const msg = document.createElement("div");
msg.className = "muted";
msg.textContent = "Unable to parse dates for this series.";
chartDiv.appendChild(msg);
return;
}
const margin = { top: 20, right: 30, bottom: 40, left: 56 };
const fullWidth = chartDiv.clientWidth || 900;
const fullHeight = 360;
const width = fullWidth - margin.left - margin.right;
const height = fullHeight - margin.top - margin.bottom;
const svg = d3
.select(chartDiv)
.append("svg")
.attr("width", fullWidth)
.attr("height", fullHeight);
const g = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const x = d3
.scaleTime()
.domain(d3.extent(parsed, (d) => d.date))
.range([0, width]);
const y = d3
.scaleLinear()
.domain([0, d3.max(parsed, (d) => d.value) || 0])
.nice()
.range([height, 0]);
const xAxis = d3.axisBottom(x).ticks(6).tickFormat(d3.timeFormat("%Y-%m-%d"));
const yAxis = d3.axisLeft(y).ticks(6);
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 line = d3
.line()
.x((d) => x(d.date))
.y((d) => y(d.value));
g.append("path")
.datum(parsed)
.attr("class", "line")
.attr("d", line);
g.selectAll(".dot")
.data(parsed)
.enter()
.append("circle")
.attr("class", "dot")
.attr("r", 3)
.attr("cx", (d) => x(d.date))
.attr("cy", (d) => y(d.value))
.append("title")
.text((d) => `${d3.timeFormat("%Y-%m-%d")(d.date)}: ${d.value}`);
}
async function runFrequency(pushState = true) {
const term = termInput.value.trim();
if (!term) {
summaryDiv.textContent = "Enter a term to begin.";
chartDiv.innerHTML = "";
return;
}
const params = {
term,
channel: channelSel.value,
interval: intervalSel.value,
start: startInput.value,
end: endInput.value,
};
if (pushState) updateUrl(params);
const search = new URLSearchParams();
search.set("term", term);
if (params.channel && params.channel !== "all") {
search.set("channel_id", params.channel);
}
search.set("interval", params.interval);
if (params.start) search.set("start", params.start);
if (params.end) search.set("end", params.end);
summaryDiv.textContent = "Loading…";
chartDiv.innerHTML = "";
try {
const res = await fetch(`/api/frequency?${search.toString()}`);
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`);
}
const payload = await res.json();
const total = payload.totalResults || 0;
summaryDiv.textContent = `Matches: ${total.toLocaleString()} • Buckets: ${
(payload.buckets || []).length
} • Interval: ${payload.interval}`;
drawChart(payload.buckets || []);
} catch (err) {
console.error(err);
summaryDiv.textContent = "Failed to load data.";
}
}
runBtn.addEventListener("click", () => runFrequency());
termInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") runFrequency();
});
intervalSel.addEventListener("change", () => runFrequency());
channelSel.addEventListener("change", () => runFrequency());
startInput.addEventListener("change", () => runFrequency());
endInput.addEventListener("change", () => runFrequency());
window.addEventListener("popstate", () => {
qs = new URLSearchParams(window.location.search);
const params = setFormFromParams();
channelSel.value = params.channel;
runFrequency(false);
});
const initialParams = setFormFromParams();
loadChannels(initialParams.channel).then(() => {
if (initialParams.term) {
runFrequency(false);
} else {
summaryDiv.textContent = "Enter a term to begin.";
}
});
})();

63
static/index.html Normal file
View File

@@ -0,0 +1,63 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>This Little Corner (Python)</title>
<link rel="stylesheet" href="/static/style.css" />
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
</head>
<body>
<header>
<h1>This Little Corner — Elastic Search</h1>
<p class="muted">
Enter a phrase to query title, description, and transcript text.
</p>
</header>
<section class="controls">
<input id="q" type="text" placeholder="Search..." size="40" />
<details id="channelDropdown" class="channel-dropdown">
<summary id="channelSummary">All Channels</summary>
<div id="channelOptions" class="channel-options muted">
<div>Loading channels…</div>
</div>
</details>
<select id="sort">
<option value="relevant">Most relevant</option>
<option value="newer">Newest first</option>
<option value="older">Oldest first</option>
</select>
<select id="size">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
<button id="searchBtn">Search</button>
</section>
<section class="controls muted">
<label><input type="checkbox" id="exactToggle" checked /> Exact</label>
<label><input type="checkbox" id="fuzzyToggle" checked /> Fuzzy</label>
<label><input type="checkbox" id="phraseToggle" checked /> Phrase</label>
<label><input type="checkbox" id="queryStringToggle" /> Query string mode</label>
</section>
<section class="summary-row">
<div class="summary-left">
<section id="meta" class="muted"></section>
<section id="metrics">
<div id="metricsStatus" class="muted"></div>
<div id="metricsContent"></div>
</section>
</div>
<div class="summary-right">
<section id="frequencySummary" class="muted"></section>
<div id="frequencyChart"></div>
</div>
</section>
<section id="results"></section>
<script src="/static/app.js"></script>
</body>
</html>

225
static/style.css Normal file
View File

@@ -0,0 +1,225 @@
body {
font-family: Arial, sans-serif;
margin: 24px;
color: #222;
}
header {
margin-bottom: 16px;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
.channel-dropdown {
position: relative;
min-width: 220px;
flex: 0 1 260px;
}
.channel-dropdown summary {
list-style: none;
cursor: pointer;
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 8px;
background: #fff;
color: #222;
display: inline-flex;
align-items: center;
min-height: 32px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-dropdown summary::-webkit-details-marker {
display: none;
}
.channel-dropdown[open] summary {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.channel-options {
margin-top: 4px;
padding: 8px;
border: 1px solid #ccc;
border-radius: 0 0 4px 4px;
background: #fff;
max-height: 240px;
overflow-y: auto;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
min-width: 220px;
width: max(220px, 100%);
}
.channel-option {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 12px;
}
.channel-option:last-child {
margin-bottom: 0;
}
input,
select,
button {
padding: 6px 8px;
}
.muted {
color: #666;
font-size: 12px;
}
#results .item {
border-bottom: 1px solid #ddd;
padding: 12px 0;
}
.summary-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: flex-start;
margin-top: 12px;
}
.summary-left {
flex: 0 1 280px;
max-width: 360px;
}
.summary-right {
flex: 1 1 0%;
min-width: 0;
background: #f5f5f5;
padding: 12px;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
#metrics {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
#metricsStatus {
min-height: 16px;
}
#metricsContent {
display: flex;
flex-direction: column;
gap: 6px;
}
#frequencyChart {
margin-top: 8px;
}
#frequencyChart svg {
max-width: 100%;
}
#frequencyChart .axis path,
#frequencyChart .axis line {
stroke: #ccc;
}
#frequencyChart .freq-layer rect {
stroke: #fff;
stroke-width: 0.5px;
}
.freq-legend {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 12px;
color: #444;
}
.freq-legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.freq-legend-swatch {
width: 12px;
height: 12px;
border-radius: 2px;
display: inline-block;
}
.transcript {
background: #fafafa;
padding: 8px;
margin-top: 6px;
max-height: 200px;
overflow-y: auto;
}
.highlight-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: none;
overflow: visible;
}
.highlight-row {
padding: 4px 0;
border-bottom: 1px solid #ececec;
}
.highlight-row:last-child {
border-bottom: none;
}
.transcript-wrapper {
margin-top: 8px;
}
.pager {
margin-top: 12px;
display: flex;
gap: 8px;
}
mark {
background: #ffe58a;
padding: 0 2px;
}
.badge-row {
margin-top: 6px;
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.badge {
background: #0b6efd;
color: #fff;
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
}