mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-05 12:22:28 +00:00
421 lines
11 KiB
JavaScript
421 lines
11 KiB
JavaScript
const DATE_FORMAT_OPTIONS = {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
};
|
|
|
|
export const SORT_OPTIONS = [
|
|
{ value: "relevance", label: "Relevance" },
|
|
{ value: "newest", label: "Newest" },
|
|
{ value: "oldest", label: "Oldest" },
|
|
{ value: "views", label: "Most Views" },
|
|
{ value: "zaps", label: "Most Zaps", experimental: true },
|
|
{ value: "reactions", label: "Most Reactions/Comments", experimental: true },
|
|
];
|
|
|
|
const SORT_VALUES = new Set(SORT_OPTIONS.map((option) => option.value));
|
|
|
|
export const DEFAULT_FILTERS = {
|
|
dateRange: {
|
|
after: null,
|
|
before: null,
|
|
},
|
|
sort: "relevance",
|
|
authorPubkeys: [],
|
|
tags: [],
|
|
textScope: "all",
|
|
duration: {
|
|
minSeconds: null,
|
|
maxSeconds: null,
|
|
},
|
|
hasMagnet: null,
|
|
hasUrl: null,
|
|
hasTranscript: 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,
|
|
hasTranscript: DEFAULT_FILTERS.hasTranscript,
|
|
nsfw: DEFAULT_FILTERS.nsfw,
|
|
relay: DEFAULT_FILTERS.relay,
|
|
kind: DEFAULT_FILTERS.kind,
|
|
});
|
|
|
|
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] === '"') {
|
|
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({ value: current, isPhrase: currentIsPhrase });
|
|
current = "";
|
|
currentIsPhrase = false;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
current += char;
|
|
}
|
|
|
|
if (current) {
|
|
tokens.push({ value: current, isPhrase: currentIsPhrase });
|
|
}
|
|
|
|
if (inQuotes) {
|
|
sawUnclosedQuote = true;
|
|
}
|
|
|
|
return { tokens, sawUnclosedQuote };
|
|
};
|
|
|
|
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 = 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?.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.value);
|
|
if (!keyValue) {
|
|
textTokens.push({
|
|
type: "term",
|
|
value: token.value,
|
|
isPhrase: Boolean(token.isPhrase || token.value.includes(" ")),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const { key, value } = keyValue;
|
|
if (!value) {
|
|
errors.push({ token: token.value, 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: token.value, 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: token.value, 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: token.value, 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: token.value, message: parsed.error });
|
|
break;
|
|
}
|
|
filters.dateRange.after = parsed.value;
|
|
break;
|
|
}
|
|
case "before": {
|
|
const parsed = parseDateValue(value);
|
|
if (parsed.error) {
|
|
errors.push({ token: token.value, message: parsed.error });
|
|
break;
|
|
}
|
|
filters.dateRange.before = parsed.value;
|
|
break;
|
|
}
|
|
case "duration": {
|
|
const match = value.match(/^(<=|>=|<|>)(.+)$/);
|
|
if (!match) {
|
|
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: token.value, 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 if (normalizedValue === "transcript") {
|
|
filters.hasTranscript = true;
|
|
} else {
|
|
errors.push({
|
|
token: token.value,
|
|
message: "Has filter supports magnet, url, or transcript.",
|
|
});
|
|
}
|
|
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: 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: token.value, message: "Sort filter is invalid." });
|
|
break;
|
|
}
|
|
filters.sort = normalizedValue;
|
|
break;
|
|
}
|
|
default:
|
|
errors.push({ token: token.value, message: `Unknown filter "${key}".` });
|
|
}
|
|
}
|
|
|
|
const text = textTokens
|
|
.map((token) => {
|
|
if (token.type === "operator") {
|
|
return token.value;
|
|
}
|
|
if (token.isPhrase) {
|
|
return `"${token.value}"`;
|
|
}
|
|
return 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.hasTranscript === true) {
|
|
tokens.push("has:transcript");
|
|
}
|
|
|
|
if (filters.nsfw && filters.nsfw !== "any") {
|
|
tokens.push(`nsfw:${filters.nsfw}`);
|
|
}
|
|
|
|
if (filters.sort && filters.sort !== DEFAULT_FILTERS.sort) {
|
|
tokens.push(`sort:${filters.sort}`);
|
|
}
|
|
|
|
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;
|
|
}
|