mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
The Author field (and the author: token) passed values through as-typed, but the matcher compares against video.pubkey — HEX. So entering an npub (the only format users actually have) silently returned zero results; only raw hex worked. New normalizeAuthorValues (searchFilters.js, injectable decoder for tests): npub1* decodes via safeDecodeNpub to hex, hex passes through lowercased, and undecodable values are dropped — surfaced as parse errors on the author: token path. Applied in BOTH the parser and the filter modal's applyFilters, so the Author panel field and the token syntax now genuinely filter by creator. Tests: +2 in tests/search-filters.test.mjs (decode/passthrough/drop with an injected decoder; parser hex passthrough + garbage error). Lint + build green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
367 lines
9.7 KiB
JavaScript
367 lines
9.7 KiB
JavaScript
import { safeDecodeNpub } from "../utils/nostrHelpers.js";
|
|
|
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
|
|
// Authors arrive as npubs (what users actually have) or hex, but videos carry
|
|
// HEX pubkeys — without decoding, npub authors silently matched nothing.
|
|
// Undecodable values are dropped (returned in `dropped` for error surfaces).
|
|
export function normalizeAuthorValues(values, { decodeNpub = safeDecodeNpub } = {}) {
|
|
const normalized = [];
|
|
const dropped = [];
|
|
for (const raw of Array.isArray(values) ? values : []) {
|
|
const value = typeof raw === "string" ? raw.trim() : "";
|
|
if (!value) continue;
|
|
if (HEX64.test(value.toLowerCase())) {
|
|
normalized.push(value.toLowerCase());
|
|
continue;
|
|
}
|
|
if (value.toLowerCase().startsWith("npub1")) {
|
|
const hex = typeof decodeNpub === "function" ? decodeNpub(value) : null;
|
|
if (hex && HEX64.test(hex.toLowerCase())) {
|
|
normalized.push(hex.toLowerCase());
|
|
continue;
|
|
}
|
|
}
|
|
dropped.push(value);
|
|
}
|
|
return { normalized, dropped };
|
|
}
|
|
|
|
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;
|
|
}
|
|
{
|
|
const { normalized, dropped } = normalizeAuthorValues(values);
|
|
filters.authorPubkeys.push(...normalized);
|
|
for (const value of dropped) {
|
|
errors.push({ token, message: `Could not decode author "${value}".` });
|
|
}
|
|
}
|
|
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(" ");
|
|
}
|