From 40707e76af8ef22917d272ee5eb1274efc31ef53 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 23 Jan 2026 11:08:16 -0500 Subject: [PATCH] Add search filter parsing and hash persistence --- js/index.js | 34 +++- js/search/searchFilters.js | 340 +++++++++++++++++++++++++++++++++++++ js/searchView.js | 13 +- 3 files changed, 382 insertions(+), 5 deletions(-) create mode 100644 js/search/searchFilters.js diff --git a/js/index.js b/js/index.js index 55224106..93299ee1 100644 --- a/js/index.js +++ b/js/index.js @@ -18,6 +18,10 @@ import { } from "./themeController.js"; import { setHashView } from "./hashView.js"; import { devLogger, userLogger } from "./utils/logger.js"; +import { + parseFilterQuery, + serializeFiltersToQuery, +} from "./search/searchFilters.js"; import { prepareStaticModal, openStaticModal, @@ -730,7 +734,20 @@ async function bootstrapInterface() { // Not a valid npub, proceed to search } - setHashView(`search&q=${encodeURIComponent(query)}`); + const parsedFilters = parseFilterQuery(query); + if (parsedFilters.errors.length > 0) { + devLogger.warn("[Search] Filter parsing errors", parsedFilters.errors); + } + const params = new URLSearchParams(); + if (parsedFilters.text) { + params.set("q", parsedFilters.text); + } + const serializedFilters = serializeFiltersToQuery(parsedFilters.filters); + if (serializedFilters) { + params.set("filters", serializedFilters); + } + const queryString = params.toString(); + setHashView(queryString ? `search&${queryString}` : "search"); }); } @@ -827,7 +844,20 @@ async function bootstrapInterface() { // Not a valid npub, proceed to search } - setHashView(`search&q=${encodeURIComponent(query)}`); + const parsedFilters = parseFilterQuery(query); + if (parsedFilters.errors.length > 0) { + devLogger.warn("[Search] Filter parsing errors", parsedFilters.errors); + } + const params = new URLSearchParams(); + if (parsedFilters.text) { + params.set("q", parsedFilters.text); + } + const serializedFilters = serializeFiltersToQuery(parsedFilters.filters); + if (serializedFilters) { + params.set("filters", serializedFilters); + } + const queryString = params.toString(); + setHashView(queryString ? `search&${queryString}` : "search"); // Close search on submit mobileSearchContainer.classList.add("hidden"); mobileSearchFab.classList.remove("hidden"); diff --git a/js/search/searchFilters.js b/js/search/searchFilters.js new file mode 100644 index 00000000..95c51987 --- /dev/null +++ b/js/search/searchFilters.js @@ -0,0 +1,340 @@ +const DATE_FORMAT_OPTIONS = { + year: "numeric", + month: "2-digit", + day: "2-digit", +}; + +export const DEFAULT_FILTERS = { + dateRange: { + after: null, + before: null, + }, + sort: "relevance", + authorPubkeys: [], + tags: [], + textScope: "all", + duration: { + minSeconds: null, + maxSeconds: null, + }, + hasMagnet: null, + hasUrl: null, + nsfw: "any", + relay: null, + kind: null, +}; + +const BOOLEAN_OPERATORS = new Set(["AND", "OR", "NOT"]); + +const cloneDefaultFilters = () => ({ + dateRange: { ...DEFAULT_FILTERS.dateRange }, + sort: DEFAULT_FILTERS.sort, + authorPubkeys: [], + tags: [], + textScope: DEFAULT_FILTERS.textScope, + duration: { ...DEFAULT_FILTERS.duration }, + hasMagnet: DEFAULT_FILTERS.hasMagnet, + hasUrl: DEFAULT_FILTERS.hasUrl, + 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 inQuotes = false; + + for (let i = 0; i < input.length; i += 1) { + const char = input[i]; + if (char === '"' && input[i - 1] !== "\\") { + inQuotes = !inQuotes; + continue; + } + + if (!inQuotes && /\s/.test(char)) { + if (current) { + tokens.push(current); + current = ""; + } + continue; + } + + current += char; + } + + if (current) { + tokens.push(current); + } + + return tokens; +}; + +const parseDateValue = (value) => { + if (!value) return { error: "Date value is missing." }; + if (/^\d+$/.test(value)) { + const timestamp = Number.parseInt(value, 10); + if (!Number.isFinite(timestamp)) { + return { error: "Date timestamp is invalid." }; + } + return { value: timestamp }; + } + + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) { + return { error: "Date value is invalid." }; + } + + return { value: Math.floor(parsed / 1000) }; +}; + +const parseDurationValue = (value) => { + const match = value.match(/^(\d+(?:\.\d+)?)(s|m|h)?$/i); + if (!match) { + return { error: "Duration format is invalid." }; + } + + const amount = Number.parseFloat(match[1]); + if (!Number.isFinite(amount)) { + return { error: "Duration value is invalid." }; + } + + const unit = (match[2] || "s").toLowerCase(); + const multiplier = unit === "h" ? 3600 : unit === "m" ? 60 : 1; + + return { value: Math.round(amount * multiplier) }; +}; + +const applyDurationFilter = (filters, operator, seconds) => { + if (operator === "<" || operator === "<=") { + filters.duration.maxSeconds = seconds; + } else if (operator === ">" || operator === ">=") { + filters.duration.minSeconds = seconds; + } +}; + +const parseKeyValueToken = (token) => { + const match = token.match(/^([a-zA-Z]+):(.*)$/); + if (!match) return null; + return { + key: match[1].toLowerCase(), + value: match[2] || "", + }; +}; + +const formatDateForFilter = (timestampSeconds) => { + if (!Number.isFinite(timestampSeconds)) return ""; + const date = new Date(timestampSeconds * 1000); + const parts = new Intl.DateTimeFormat("en-CA", DATE_FORMAT_OPTIONS).formatToParts( + date, + ); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.year}-${map.month}-${map.day}`; +}; + +const formatDurationValue = (seconds) => { + if (!Number.isFinite(seconds)) return ""; + if (seconds % 3600 === 0) return `${seconds / 3600}h`; + if (seconds % 60 === 0) return `${seconds / 60}m`; + return `${seconds}s`; +}; + +export function parseFilterQuery(inputString = "") { + const normalized = normalizeWhitespace(String(inputString || "")); + const tokens = tokenizeQuery(normalized); + const filters = cloneDefaultFilters(); + const errors = []; + const textTokens = []; + + for (const token of tokens) { + if (!token) continue; + const upperToken = token.toUpperCase(); + if (BOOLEAN_OPERATORS.has(upperToken)) { + textTokens.push({ type: "operator", value: upperToken }); + continue; + } + + const keyValue = parseKeyValueToken(token); + if (!keyValue) { + textTokens.push({ type: "term", value: token, isPhrase: token.includes(" ") }); + continue; + } + + const { key, value } = keyValue; + if (!value) { + errors.push({ token, message: "Filter value is missing." }); + continue; + } + + switch (key) { + case "author": { + const values = value.split(",").map((entry) => entry.trim()).filter(Boolean); + if (values.length === 0) { + errors.push({ token, message: "Author value is empty." }); + break; + } + filters.authorPubkeys.push(...values); + break; + } + case "tag": { + const values = value + .split(",") + .map((entry) => entry.trim().replace(/^#/, "")) + .filter(Boolean); + if (values.length === 0) { + errors.push({ token, message: "Tag value is empty." }); + break; + } + filters.tags.push(...values); + break; + } + case "kind": { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + errors.push({ token, message: "Kind must be an integer." }); + break; + } + filters.kind = parsed; + break; + } + case "relay": { + filters.relay = value; + break; + } + case "after": { + const parsed = parseDateValue(value); + if (parsed.error) { + errors.push({ token, message: parsed.error }); + break; + } + filters.dateRange.after = parsed.value; + break; + } + case "before": { + const parsed = parseDateValue(value); + if (parsed.error) { + errors.push({ token, message: parsed.error }); + break; + } + filters.dateRange.before = parsed.value; + break; + } + case "duration": { + const match = value.match(/^(<=|>=|<|>)(.+)$/); + if (!match) { + errors.push({ token, 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 }); + break; + } + applyDurationFilter(filters, operator, parsed.value); + break; + } + case "has": { + const normalizedValue = value.toLowerCase(); + if (normalizedValue === "magnet") { + filters.hasMagnet = true; + } else if (normalizedValue === "url") { + filters.hasUrl = true; + } else { + errors.push({ token, message: "Has filter supports magnet or url." }); + } + break; + } + case "nsfw": { + const normalizedValue = value.toLowerCase(); + 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." }); + } + break; + } + default: + errors.push({ token, message: `Unknown filter "${key}".` }); + } + } + + const text = textTokens + .filter((token) => token.type === "term") + .map((token) => token.value) + .join(" "); + + return { + filters, + text, + tokens: textTokens, + errors, + }; +} + +export function serializeFiltersToQuery(filters = DEFAULT_FILTERS) { + const tokens = []; + + if (filters.authorPubkeys?.length) { + for (const author of filters.authorPubkeys) { + tokens.push(`author:${author}`); + } + } + + if (filters.tags?.length) { + for (const tag of filters.tags) { + tokens.push(`tag:${tag}`); + } + } + + if (Number.isFinite(filters.kind)) { + tokens.push(`kind:${filters.kind}`); + } + + if (filters.relay) { + tokens.push(`relay:${filters.relay}`); + } + + if (Number.isFinite(filters.dateRange?.after)) { + tokens.push(`after:${formatDateForFilter(filters.dateRange.after)}`); + } + + if (Number.isFinite(filters.dateRange?.before)) { + tokens.push(`before:${formatDateForFilter(filters.dateRange.before)}`); + } + + if (Number.isFinite(filters.duration?.minSeconds)) { + tokens.push(`duration:>=${formatDurationValue(filters.duration.minSeconds)}`); + } + + if (Number.isFinite(filters.duration?.maxSeconds)) { + tokens.push(`duration:<=${formatDurationValue(filters.duration.maxSeconds)}`); + } + + if (filters.hasMagnet === true) { + tokens.push("has:magnet"); + } + + if (filters.hasUrl === true) { + tokens.push("has:url"); + } + + if (filters.nsfw && filters.nsfw !== "any") { + tokens.push(`nsfw:${filters.nsfw}`); + } + + return tokens.join(" "); +} + +export function filtersToHashParams(filters = DEFAULT_FILTERS) { + const params = new URLSearchParams(); + const serialized = serializeFiltersToQuery(filters); + if (serialized) { + params.set("filters", serialized); + } + return params; +} diff --git a/js/searchView.js b/js/searchView.js index a762f0f5..1e26d078 100644 --- a/js/searchView.js +++ b/js/searchView.js @@ -8,6 +8,7 @@ import { formatShortNpub } from "./utils/formatters.js"; import { sanitizeProfileMediaUrl } from "./utils/profileMedia.js"; import { devLogger } from "./utils/logger.js"; import { attachFeedInfoPopover } from "./ui/components/FeedInfoPopover.js"; +import { parseFilterQuery } from "./search/searchFilters.js"; function getApp() { return getApplication(); @@ -101,9 +102,15 @@ let currentSearchToken = 0; export async function initSearchView() { const hashParams = new URLSearchParams(window.location.hash.split("?")[1] || window.location.hash.slice(1)); - // The hash format is #view=search&q=... + // The hash format is #view=search&q=...&filters=... // URLSearchParams handles 'view=search&q=...' correctly if we pass the string after # - const query = hashParams.get("q") || ""; + const rawQuery = hashParams.get("q") || ""; + const rawFilters = hashParams.get("filters") || ""; + const parsedQuery = parseFilterQuery([rawQuery, rawFilters].filter(Boolean).join(" ")); + if (parsedQuery.errors.length > 0) { + devLogger.warn("[Search] Filter parsing errors", parsedQuery.errors); + } + const query = parsedQuery.text || ""; const titleEl = document.getElementById("searchTitle"); if (titleEl) { @@ -114,7 +121,7 @@ export async function initSearchView() { if (infoTrigger) { attachFeedInfoPopover( infoTrigger, - "Results matching your search query." + "Results matching your search query. Use tokens like author:, tag:, kind:, relay:, after:, before:, duration:<, and has:magnet/url." ); }