Enhance search query parsing and filters

This commit is contained in:
thePR0M3TH3AN committed 2026-01-23 13:13:40 -05:00
1 parent 9774c4e3eb
commit 19aa272bae
6 files changed
+351 -43

No files matched your search

+14
View File
@@ -215,6 +215,13 @@
></span>
</button>
</form>
<p
id="headerSearchMessage"
class="mt-1 text-xs text-warning hidden"
role="status"
aria-live="polite"
data-search-parse-message
></p>
</div>
<!-- Buttons on the far right -->
@@ -413,6 +420,13 @@
></span>
</button>
</form>
<p
id="mobileSearchMessage"
class="mt-2 text-xs text-warning hidden"
role="status"
aria-live="polite"
data-search-parse-message
></p>
</div>
</div>
+100 -9
View File
@@ -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");
+2
View File
@@ -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:
+86 -30
View File
@@ -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}`);
}
+19 -1
View File
@@ -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);
+130 -3
View File
@@ -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;
}