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(" "); }