From 19aa272baecec9e2179f045793a6090cbf6b9244 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:13:40 -0500 Subject: [PATCH] Enhance search query parsing and filters --- index.html | 14 +++ js/index.js | 109 +++++++++++++++++-- js/search/searchFilterState.js | 2 + js/search/searchFilters.js | 116 +++++++++++++++----- js/searchView.js | 20 +++- js/ui/components/SearchFiltersPopover.js | 133 ++++++++++++++++++++++- 6 files changed, 351 insertions(+), 43 deletions(-) diff --git a/index.html b/index.html index 2d6d1785..d7077dc7 100644 --- a/index.html +++ b/index.html @@ -215,6 +215,13 @@ > + @@ -413,6 +420,13 @@ > + diff --git a/js/index.js b/js/index.js index 4e9026b3..13ddc4ca 100644 --- a/js/index.js +++ b/js/index.js @@ -18,7 +18,10 @@ import { } from "./themeController.js"; import { setHashView } from "./hashView.js"; import { devLogger, userLogger } from "./utils/logger.js"; -import { parseFilterQuery } from "./search/searchFilters.js"; +import { + parseFilterQuery, + serializeFiltersToQuery, +} from "./search/searchFilters.js"; import { buildSearchHashFromState, getSearchFilterState, @@ -703,7 +706,49 @@ async function bootstrapInterface() { label: "Sidebar Tip Jar link", }); + const buildSearchQueryString = (text, filters) => { + const serializedFilters = serializeFiltersToQuery(filters); + return [text, serializedFilters].filter(Boolean).join(" ").trim(); + }; + + const updateSearchParseMessage = (messageEl, errors = []) => { + if (!(messageEl instanceof HTMLElement)) { + return; + } + if (!errors?.length) { + messageEl.textContent = ""; + messageEl.classList.add("hidden"); + return; + } + const message = errors + .map((error) => + error?.token ? `${error.token} (${error.message})` : error?.message, + ) + .filter(Boolean) + .join(" • "); + messageEl.textContent = message || "Some filters could not be parsed."; + messageEl.classList.remove("hidden"); + }; + + const updateSearchInputsValue = (value, sourceInput = null) => { + const inputs = [ + document.getElementById("headerSearchInput"), + document.getElementById("mobileSearchInput"), + ]; + inputs.forEach((input) => { + if (!(input instanceof HTMLInputElement)) { + return; + } + if (sourceInput && input === sourceInput) { + input.value = value; + return; + } + input.value = value; + }); + }; + const headerSearchForm = document.getElementById("headerSearchForm"); + const headerSearchMessage = document.getElementById("headerSearchMessage"); if (headerSearchForm) { headerSearchForm.addEventListener("submit", async (event) => { event.preventDefault(); @@ -742,12 +787,19 @@ async function bootstrapInterface() { if (parsedFilters.errors.length > 0) { devLogger.warn("[Search] Filter parsing errors", parsedFilters.errors); } + updateSearchParseMessage(headerSearchMessage, parsedFilters.errors); const nextState = { text: parsedFilters.text || "", filters: parsedFilters.filters, }; setSearchFilterState(nextState); setHashView(buildSearchHashFromState(nextState)); + if (parsedFilters.errors.length === 0) { + updateSearchInputsValue( + buildSearchQueryString(parsedFilters.text, parsedFilters.filters), + input, + ); + } }); } @@ -758,6 +810,7 @@ async function bootstrapInterface() { ); const mobileSearchForm = document.getElementById("mobileSearchForm"); const mobileSearchInput = document.getElementById("mobileSearchInput"); + const mobileSearchMessage = document.getElementById("mobileSearchMessage"); if (mobileSearchFab && mobileSearchContainer) { const openMobileSearch = () => { @@ -848,12 +901,19 @@ async function bootstrapInterface() { if (parsedFilters.errors.length > 0) { devLogger.warn("[Search] Filter parsing errors", parsedFilters.errors); } + updateSearchParseMessage(mobileSearchMessage, parsedFilters.errors); const nextState = { text: parsedFilters.text || "", filters: parsedFilters.filters, }; setSearchFilterState(nextState); setHashView(buildSearchHashFromState(nextState)); + if (parsedFilters.errors.length === 0) { + updateSearchInputsValue( + buildSearchQueryString(parsedFilters.text, parsedFilters.filters), + mobileSearchInput, + ); + } // Close search on submit mobileSearchContainer.classList.add("hidden"); mobileSearchFab.classList.remove("hidden"); @@ -861,26 +921,57 @@ async function bootstrapInterface() { } const searchFilterButtons = document.querySelectorAll(".header-search__filter"); - const applySearchFilters = (filters) => { - const currentState = getSearchFilterState(); + const applySearchFilters = (filters, context = {}) => { + const rawQuery = context.input?.value || ""; + const parsed = parseFilterQuery(rawQuery); + updateSearchParseMessage(context.messageEl, parsed.errors); const nextState = { - text: currentState.text || "", + text: parsed.text || "", filters, }; setSearchFilterState(nextState); setHashView(buildSearchHashFromState(nextState)); + const nextQuery = buildSearchQueryString(nextState.text, nextState.filters); + updateSearchInputsValue(nextQuery, context.input || null); + const refreshed = parseFilterQuery(nextQuery); + updateSearchParseMessage(context.messageEl, refreshed.errors); }; - const clearSearchFilters = () => { - resetSearchFilters(); + const clearSearchFilters = (context = {}) => { + const rawQuery = context.input?.value || ""; + const parsed = parseFilterQuery(rawQuery); + resetSearchFilters({ notify: false }); const nextState = getSearchFilterState(); + nextState.text = parsed.text || ""; + setSearchFilterState(nextState); setHashView(buildSearchHashFromState(nextState)); + updateSearchInputsValue(nextState.text, context.input || null); + updateSearchParseMessage(context.messageEl, []); }; searchFilterButtons.forEach((button) => { - attachSearchFiltersPopover(button, { + const form = button.closest("form"); + const input = form?.querySelector("input[type=\"search\"]") || null; + const messageEl = form?.parentElement?.querySelector( + "[data-search-parse-message]", + ); + const popover = attachSearchFiltersPopover(button, { getState: getSearchFilterState, - onApply: applySearchFilters, - onReset: clearSearchFilters, + getQueryInputValue: () => (input?.value ? input.value : ""), + onQueryParsed: (parsed) => { + updateSearchParseMessage(messageEl, parsed.errors); + }, + onApply: (filters) => + applySearchFilters(filters, { input, messageEl }), + onReset: () => clearSearchFilters({ input, messageEl }), }); + if (input && popover) { + input.addEventListener("input", () => { + if (!popover.isOpen()) { + return; + } + const parsed = popover.syncFromQueryInput(input.value || ""); + updateSearchParseMessage(messageEl, parsed.errors); + }); + } }); const sidebar = document.getElementById("sidebar"); diff --git a/js/search/searchFilterState.js b/js/search/searchFilterState.js index 6799418c..23de19d9 100644 --- a/js/search/searchFilterState.js +++ b/js/search/searchFilterState.js @@ -24,6 +24,8 @@ const cloneFilters = (filters = DEFAULT_FILTERS) => ({ hasMagnet: typeof filters?.hasMagnet === "boolean" ? filters.hasMagnet : null, hasUrl: typeof filters?.hasUrl === "boolean" ? filters.hasUrl : null, + hasTranscript: + typeof filters?.hasTranscript === "boolean" ? filters.hasTranscript : null, nsfw: filters?.nsfw ?? DEFAULT_FILTERS.nsfw, relay: filters?.relay ?? DEFAULT_FILTERS.relay, kind: diff --git a/js/search/searchFilters.js b/js/search/searchFilters.js index 330e87da..547be3bc 100644 --- a/js/search/searchFilters.js +++ b/js/search/searchFilters.js @@ -30,6 +30,7 @@ export const DEFAULT_FILTERS = { }, hasMagnet: null, hasUrl: null, + hasTranscript: null, nsfw: "any", relay: null, kind: null, @@ -46,30 +47,51 @@ const cloneDefaultFilters = () => ({ duration: { ...DEFAULT_FILTERS.duration }, hasMagnet: DEFAULT_FILTERS.hasMagnet, hasUrl: DEFAULT_FILTERS.hasUrl, + hasTranscript: DEFAULT_FILTERS.hasTranscript, nsfw: DEFAULT_FILTERS.nsfw, relay: DEFAULT_FILTERS.relay, kind: DEFAULT_FILTERS.kind, }); -const normalizeWhitespace = (input) => - input.replace(/\s+/g, " ").trim(); - const tokenizeQuery = (input) => { const tokens = []; let current = ""; + let currentIsPhrase = false; let inQuotes = false; + let sawUnclosedQuote = false; for (let i = 0; i < input.length; i += 1) { const char = input[i]; - if (char === '"' && input[i - 1] !== "\\") { - inQuotes = !inQuotes; + + if (char === "\\" && input[i + 1] === '"') { + current += '"'; + i += 1; + continue; + } + + if (char === '"') { + if (inQuotes) { + tokens.push({ value: current, isPhrase: true }); + current = ""; + currentIsPhrase = false; + inQuotes = false; + } else { + if (current) { + tokens.push({ value: current, isPhrase: currentIsPhrase }); + current = ""; + currentIsPhrase = false; + } + inQuotes = true; + currentIsPhrase = true; + } continue; } if (!inQuotes && /\s/.test(char)) { if (current) { - tokens.push(current); + tokens.push({ value: current, isPhrase: currentIsPhrase }); current = ""; + currentIsPhrase = false; } continue; } @@ -78,10 +100,14 @@ const tokenizeQuery = (input) => { } if (current) { - tokens.push(current); + tokens.push({ value: current, isPhrase: currentIsPhrase }); } - return tokens; + if (inQuotes) { + sawUnclosedQuote = true; + } + + return { tokens, sawUnclosedQuote }; }; const parseDateValue = (value) => { @@ -154,29 +180,37 @@ const formatDurationValue = (seconds) => { }; export function parseFilterQuery(inputString = "") { - const normalized = normalizeWhitespace(String(inputString || "")); - const tokens = tokenizeQuery(normalized); + const normalized = String(inputString || "").trim(); + const { tokens, sawUnclosedQuote } = tokenizeQuery(normalized); const filters = cloneDefaultFilters(); const errors = []; const textTokens = []; + if (sawUnclosedQuote) { + errors.push({ token: "\"", message: "Missing closing quote." }); + } + for (const token of tokens) { - if (!token) continue; - const upperToken = token.toUpperCase(); - if (BOOLEAN_OPERATORS.has(upperToken)) { - textTokens.push({ type: "operator", value: upperToken }); + if (!token?.value) continue; + const upperToken = token.value.toUpperCase(); + if (!token.isPhrase && BOOLEAN_OPERATORS.has(upperToken)) { + textTokens.push({ type: "operator", value: upperToken, isPhrase: false }); continue; } - const keyValue = parseKeyValueToken(token); + const keyValue = parseKeyValueToken(token.value); if (!keyValue) { - textTokens.push({ type: "term", value: token, isPhrase: token.includes(" ") }); + textTokens.push({ + type: "term", + value: token.value, + isPhrase: Boolean(token.isPhrase || token.value.includes(" ")), + }); continue; } const { key, value } = keyValue; if (!value) { - errors.push({ token, message: "Filter value is missing." }); + errors.push({ token: token.value, message: "Filter value is missing." }); continue; } @@ -184,7 +218,7 @@ export function parseFilterQuery(inputString = "") { case "author": { const values = value.split(",").map((entry) => entry.trim()).filter(Boolean); if (values.length === 0) { - errors.push({ token, message: "Author value is empty." }); + errors.push({ token: token.value, message: "Author value is empty." }); break; } filters.authorPubkeys.push(...values); @@ -196,7 +230,7 @@ export function parseFilterQuery(inputString = "") { .map((entry) => entry.trim().replace(/^#/, "")) .filter(Boolean); if (values.length === 0) { - errors.push({ token, message: "Tag value is empty." }); + errors.push({ token: token.value, message: "Tag value is empty." }); break; } filters.tags.push(...values); @@ -205,7 +239,7 @@ export function parseFilterQuery(inputString = "") { case "kind": { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed)) { - errors.push({ token, message: "Kind must be an integer." }); + errors.push({ token: token.value, message: "Kind must be an integer." }); break; } filters.kind = parsed; @@ -218,7 +252,7 @@ export function parseFilterQuery(inputString = "") { case "after": { const parsed = parseDateValue(value); if (parsed.error) { - errors.push({ token, message: parsed.error }); + errors.push({ token: token.value, message: parsed.error }); break; } filters.dateRange.after = parsed.value; @@ -227,7 +261,7 @@ export function parseFilterQuery(inputString = "") { case "before": { const parsed = parseDateValue(value); if (parsed.error) { - errors.push({ token, message: parsed.error }); + errors.push({ token: token.value, message: parsed.error }); break; } filters.dateRange.before = parsed.value; @@ -236,14 +270,17 @@ export function parseFilterQuery(inputString = "") { case "duration": { const match = value.match(/^(<=|>=|<|>)(.+)$/); if (!match) { - errors.push({ token, message: "Duration must use < or > operators." }); + errors.push({ + token: token.value, + message: "Duration must use < or > operators.", + }); break; } const operator = match[1]; const durationValue = match[2].trim(); const parsed = parseDurationValue(durationValue); if (parsed.error) { - errors.push({ token, message: parsed.error }); + errors.push({ token: token.value, message: parsed.error }); break; } applyDurationFilter(filters, operator, parsed.value); @@ -255,8 +292,13 @@ export function parseFilterQuery(inputString = "") { filters.hasMagnet = true; } else if (normalizedValue === "url") { filters.hasUrl = true; + } else if (normalizedValue === "transcript") { + filters.hasTranscript = true; } else { - errors.push({ token, message: "Has filter supports magnet or url." }); + errors.push({ + token: token.value, + message: "Has filter supports magnet, url, or transcript.", + }); } break; } @@ -265,27 +307,37 @@ export function parseFilterQuery(inputString = "") { if (["any", "true", "false", "only", "safe"].includes(normalizedValue)) { filters.nsfw = normalizedValue === "safe" ? "false" : normalizedValue; } else { - errors.push({ token, message: "NSFW filter supports any/true/false/only." }); + errors.push({ + token: token.value, + message: "NSFW filter supports any/true/false/only.", + }); } break; } case "sort": { const normalizedValue = value.toLowerCase(); if (!SORT_VALUES.has(normalizedValue)) { - errors.push({ token, message: "Sort filter is invalid." }); + errors.push({ token: token.value, message: "Sort filter is invalid." }); break; } filters.sort = normalizedValue; break; } default: - errors.push({ token, message: `Unknown filter "${key}".` }); + errors.push({ token: token.value, message: `Unknown filter "${key}".` }); } } const text = textTokens - .filter((token) => token.type === "term") - .map((token) => token.value) + .map((token) => { + if (token.type === "operator") { + return token.value; + } + if (token.isPhrase) { + return `"${token.value}"`; + } + return token.value; + }) .join(" "); return { @@ -343,6 +395,10 @@ export function serializeFiltersToQuery(filters = DEFAULT_FILTERS) { tokens.push("has:url"); } + if (filters.hasTranscript === true) { + tokens.push("has:transcript"); + } + if (filters.nsfw && filters.nsfw !== "any") { tokens.push(`nsfw:${filters.nsfw}`); } diff --git a/js/searchView.js b/js/searchView.js index 38eef3d8..b8469d19 100644 --- a/js/searchView.js +++ b/js/searchView.js @@ -129,7 +129,7 @@ export async function initSearchView() { if (infoTrigger) { attachFeedInfoPopover( infoTrigger, - "Results matching your search query. Use tokens like author:, tag:, kind:, relay:, after:, before:, duration:<, and has:magnet/url." + "Results matching your search query. Use tokens like author:, tag:, kind:, relay:, after:, before:, duration:<, has:magnet/url/transcript, and boolean operators with quoted phrases." ); } @@ -442,6 +442,17 @@ function renderActiveFilters(filters) { }); } + if (filters.hasTranscript === true) { + pills.push({ + label: "Has transcript", + onRemove: () => { + const nextFilters = buildNextFilters(); + nextFilters.hasTranscript = null; + applyFiltersAndRefresh(nextFilters); + }, + }); + } + if (filters.nsfw && filters.nsfw !== "any") { pills.push({ label: `NSFW: ${filters.nsfw}`, @@ -771,6 +782,7 @@ async function performVideoSearch(query, token, filters = {}) { : null; const hasMagnet = filters?.hasMagnet === true; const hasUrl = filters?.hasUrl === true; + const hasTranscript = filters?.hasTranscript === true; const nsfwFilter = typeof filters?.nsfw === "string" ? filters.nsfw : "any"; const matchesRelayFilters = (video) => { @@ -807,6 +819,12 @@ async function performVideoSearch(query, token, filters = {}) { if (hasMagnet && !video.magnet) return false; if (hasUrl && !video.url) return false; + if (hasTranscript) { + const textTracks = Array.isArray(video.nip71?.textTracks) + ? video.nip71.textTracks + : []; + if (!textTracks.length) return false; + } if (Number.isFinite(minDuration) || Number.isFinite(maxDuration)) { const duration = Number(video.nip71?.duration); diff --git a/js/ui/components/SearchFiltersPopover.js b/js/ui/components/SearchFiltersPopover.js index 1dfec089..82485ada 100644 --- a/js/ui/components/SearchFiltersPopover.js +++ b/js/ui/components/SearchFiltersPopover.js @@ -1,5 +1,9 @@ import createPopover from "../overlay/popoverEngine.js"; -import { DEFAULT_FILTERS, SORT_OPTIONS } from "../../search/searchFilters.js"; +import { + DEFAULT_FILTERS, + SORT_OPTIONS, + parseFilterQuery, +} from "../../search/searchFilters.js"; const FOCUSABLE_SELECTOR = "a[href], button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])"; @@ -187,6 +191,12 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { header.append(heading, closeButton); panel.appendChild(header); + const parseMessage = createElement(doc, "p", { + className: "text-xs text-warning hidden", + attrs: { role: "status", "aria-live": "polite" }, + }); + panel.appendChild(parseMessage); + const dateSection = createElement(doc, "div", { className: "bv-stack bv-stack--tight", }); @@ -299,7 +309,21 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { placeholder: "npub1...", }, ); - panel.appendChild(authorWrapper); + const { wrapper: kindWrapper, inputEl: kindInput } = buildLabeledInput(doc, { + id: "search-filters-kind", + label: "Kind", + type: "number", + placeholder: "30078", + }); + const { wrapper: relayWrapper, inputEl: relayInput } = buildLabeledInput( + doc, + { + id: "search-filters-relay", + label: "Relay", + placeholder: "wss://relay.example.com", + }, + ); + panel.append(authorWrapper, kindWrapper, relayWrapper); const tagSection = createElement(doc, "div", { className: "bv-stack bv-stack--tight", @@ -366,6 +390,37 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { durationSection.appendChild(durationGrid); panel.appendChild(durationSection); + const hasSection = createElement(doc, "div", { + className: "bv-stack bv-stack--tight", + }); + hasSection.appendChild( + createElement(doc, "p", { + className: "text-xs font-semibold uppercase tracking-wide text-muted", + text: "Has", + }), + ); + const hasChipRow = createElement(doc, "div", { + className: "flex flex-wrap gap-2", + }); + const hasChips = new Map(); + [ + { key: "magnet", label: "Magnet" }, + { key: "url", label: "URL" }, + { key: "transcript", label: "Transcript" }, + ].forEach(({ key, label }) => { + const chip = createElement(doc, "button", { + className: "search-filter-chip focus-ring", + text: label, + attrs: { type: "button", "aria-pressed": "false" }, + }); + chip.dataset.state = "off"; + chip.addEventListener("click", () => toggleChip(chip)); + hasChips.set(key, chip); + hasChipRow.appendChild(chip); + }); + hasSection.appendChild(hasChipRow); + panel.appendChild(hasSection); + const toggleSection = createElement(doc, "div", { className: "grid gap-3", }); @@ -463,6 +518,20 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { if (sortSelect) { sortSelect.value = safeFilters.sort || DEFAULT_FILTERS.sort; } + kindInput.value = Number.isFinite(safeFilters.kind) + ? String(safeFilters.kind) + : ""; + relayInput.value = safeFilters.relay || ""; + const hasFlags = { + magnet: safeFilters.hasMagnet === true, + url: safeFilters.hasUrl === true, + transcript: safeFilters.hasTranscript === true, + }; + hasChips.forEach((chip, key) => { + const isOn = hasFlags[key] === true; + chip.setAttribute("aria-pressed", isOn ? "true" : "false"); + chip.dataset.state = isOn ? "on" : "off"; + }); }; const buildFiltersFromControls = () => { @@ -478,6 +547,9 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { .map((value) => value.trim()) .filter(Boolean); filters.authorPubkeys = authorValues; + const kindValue = Number.parseInt(kindInput.value, 10); + filters.kind = Number.isFinite(kindValue) ? kindValue : null; + filters.relay = relayInput.value.trim() || null; const selectedTags = new Set(); tagChips.forEach((chip, tag) => { if (chip.getAttribute("aria-pressed") === "true") { @@ -515,6 +587,18 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { if (sortSelect?.value) { filters.sort = sortSelect.value; } + filters.hasMagnet = + hasChips.get("magnet")?.getAttribute("aria-pressed") === "true" + ? true + : null; + filters.hasUrl = + hasChips.get("url")?.getAttribute("aria-pressed") === "true" + ? true + : null; + filters.hasTranscript = + hasChips.get("transcript")?.getAttribute("aria-pressed") === "true" + ? true + : null; return filters; }; @@ -538,6 +622,24 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { controlState = { applyStateToControls, + setParseErrors: (errors = []) => { + if (!parseMessage) return; + if (!errors?.length) { + parseMessage.textContent = ""; + parseMessage.classList.add("hidden"); + return; + } + const message = errors + .map((error) => + error?.token + ? `${error.token} (${error.message})` + : error?.message, + ) + .filter(Boolean) + .join(" • "); + parseMessage.textContent = message || "Some filters could not be parsed."; + parseMessage.classList.remove("hidden"); + }, }; return panel; }; @@ -548,6 +650,23 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { restoreFocusOnClose: true, }); + const syncFromQueryInput = (rawQuery) => { + if (typeof rawQuery !== "string") { + return { filters: DEFAULT_FILTERS, text: "", tokens: [], errors: [] }; + } + const parsed = parseFilterQuery(rawQuery); + if (controlState?.applyStateToControls) { + controlState.applyStateToControls(parsed.filters || DEFAULT_FILTERS); + } + if (controlState?.setParseErrors) { + controlState.setParseErrors(parsed.errors || []); + } + if (typeof options.onQueryParsed === "function") { + options.onQueryParsed(parsed); + } + return parsed; + }; + triggerElement.addEventListener("click", async (event) => { event.preventDefault(); if (popover.isOpen()) { @@ -555,7 +674,13 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { return; } popover.preload(); - if (controlState?.applyStateToControls) { + const rawQuery = + typeof options.getQueryInputValue === "function" + ? options.getQueryInputValue() + : null; + if (rawQuery !== null) { + syncFromQueryInput(rawQuery); + } else if (controlState?.applyStateToControls) { const nextState = typeof options.getState === "function" ? options.getState() : null; controlState.applyStateToControls(nextState?.filters || DEFAULT_FILTERS); @@ -563,6 +688,8 @@ export function attachSearchFiltersPopover(triggerElement, options = {}) { await popover.open(); }); + popover.syncFromQueryInput = syncFromQueryInput; + return popover; }