mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 01:42:27 +00:00
Add search filter parsing and hash persistence
This commit is contained in:
1 parent
725e3eb28b
commit
40707e76af
3 files changed
+382
-5
No files matched your search
+32
-2
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+10
-3
@@ -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."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user