You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
302 lines
10 KiB
302 lines
10 KiB
/** |
|
* Advanced search parser for Nostr events |
|
* Supports multiple search parameters: |
|
* - Hashtag: t:hashtag or hashtag:hashtag (filters by #t tag) |
|
* - Event IDs: Bare event IDs (hex, note1, nevent1, naddr1) work as standard search |
|
* - Plain text: becomes d-tag search for replaceable events (uses #d tag) |
|
* |
|
* Note: |
|
* - Nostr only supports single-letter tag indexes (#d, #t, #p, #e, #a, etc.) |
|
* - Kind filter is only available as URL parameter k= (e.g., ?t=bitcoin&k=1) |
|
* - Date searches and pubkey filters are not supported |
|
*/ |
|
|
|
export interface AdvancedSearchParams { |
|
dtag?: string |
|
hashtag?: string | string[] // t-tag/hashtag (uses #t tag) |
|
title?: string | string[] |
|
subject?: string | string[] |
|
description?: string | string[] |
|
author?: string | string[] |
|
pubkey?: string | string[] // Accepts: hex, npub, nprofile, or NIP-05 |
|
events?: string | string[] // Accepts: hex event ID, note, nevent, naddr (bare IDs work as standard search) |
|
type?: string | string[] |
|
// Date searches removed - not supported |
|
// Kind filter only available as URL parameter k= |
|
} |
|
|
|
/** |
|
* Normalize search term to d-tag format (lowercase, hyphenated) |
|
*/ |
|
export function normalizeToDTag(term: string): string { |
|
return term |
|
.toLowerCase() |
|
.trim() |
|
.replace(/[^\w\s-]/g, '') // Remove special characters |
|
.replace(/\s+/g, '-') // Replace spaces with hyphens |
|
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen |
|
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens |
|
} |
|
|
|
/** |
|
* Parse advanced search query |
|
*/ |
|
export function parseAdvancedSearch(query: string): AdvancedSearchParams { |
|
// Normalize the query: trim, normalize whitespace, handle multiple spaces |
|
const normalizedQuery = query |
|
.trim() |
|
.replace(/\s+/g, ' ') // Replace multiple whitespace with single space |
|
.replace(/\s*,\s*/g, ',') // Normalize spaces around commas |
|
.replace(/\s*:\s*/g, ':') // Normalize spaces around colons |
|
|
|
const params: AdvancedSearchParams = {} |
|
|
|
// Regular expressions for different parameter types |
|
// Note: Date searches, kind: prefix, and pubkey: prefix removed |
|
// Kind only available as URL parameter k= |
|
const quotedPattern = /(title|subject|description|author|type|hashtag|t):"([^"]+)"/gi |
|
const unquotedPattern = /(title|subject|description|author|type|hashtag|t):([^\s]+)/gi |
|
|
|
// Pattern to detect bare nip19 IDs (nevent, note, naddr) or hex event IDs |
|
// These start with the prefix and are base32 encoded (use word boundary to avoid partial matches) |
|
const bareEventIdPattern = /\b(nevent1|note1|naddr1)[a-z0-9]{0,58}\b/gi |
|
const hexEventIdPattern = /\b[a-f0-9]{64}\b/i |
|
|
|
// Pattern to detect bare pubkey IDs (npub, nprofile) or hex pubkeys |
|
const barePubkeyIdPattern = /\b(nprofile1|npub1)[a-z0-9]{0,58}\b/gi |
|
const nip05Pattern = /\b[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b/gi |
|
|
|
// Extract quoted parameters |
|
let match |
|
let lastIndex = 0 |
|
const usedIndices: number[] = [] |
|
const detectedEventIds: { id: string; start: number; end: number }[] = [] |
|
const detectedPubkeyIds: { id: string; start: number; end: number }[] = [] |
|
|
|
// First, detect bare event IDs (nevent, note, naddr) in the normalized query |
|
bareEventIdPattern.lastIndex = 0 |
|
while ((match = bareEventIdPattern.exec(normalizedQuery)) !== null) { |
|
const id = match[0] |
|
const start = match.index |
|
const end = start + id.length |
|
detectedEventIds.push({ id, start, end }) |
|
usedIndices.push(start, end) |
|
} |
|
|
|
// Detect bare pubkey IDs (npub, nprofile) |
|
barePubkeyIdPattern.lastIndex = 0 |
|
while ((match = barePubkeyIdPattern.exec(normalizedQuery)) !== null) { |
|
const id = match[0] |
|
const start = match.index |
|
const end = start + id.length |
|
detectedPubkeyIds.push({ id, start, end }) |
|
usedIndices.push(start, end) |
|
} |
|
|
|
// Detect NIP-05 identifiers |
|
nip05Pattern.lastIndex = 0 |
|
while ((match = nip05Pattern.exec(normalizedQuery)) !== null) { |
|
const id = match[0] |
|
const start = match.index |
|
const end = start + id.length |
|
|
|
// Skip if already used by a parameter pattern or other detected IDs |
|
if (!usedIndices.some((idx, i) => i % 2 === 0 && start >= idx && start <= usedIndices[i + 1])) { |
|
detectedPubkeyIds.push({ id, start, end }) |
|
usedIndices.push(start, end) |
|
} |
|
} |
|
|
|
// Check for hex IDs (64 character hex string) - could be either event or pubkey |
|
// We'll treat them as events by default, but they might be interpreted differently in context |
|
hexEventIdPattern.lastIndex = 0 |
|
while ((match = hexEventIdPattern.exec(normalizedQuery)) !== null) { |
|
const id = match[0] |
|
const start = match.index |
|
const end = start + id.length |
|
|
|
// Only add if not already in a detected ID range |
|
if (!usedIndices.some((idx, i) => i % 2 === 0 && start >= idx && start <= usedIndices[i + 1])) { |
|
// Default to treating as event ID (most common case for hex IDs in Nostr) |
|
detectedEventIds.push({ id, start, end }) |
|
usedIndices.push(start, end) |
|
} |
|
} |
|
|
|
// Helper function to parse comma-separated values |
|
const parseValues = (value: string): string[] => { |
|
return value.split(',').map(v => v.trim()).filter(v => v.length > 0) |
|
} |
|
|
|
// Process quoted strings first (they can contain spaces) |
|
while ((match = quotedPattern.exec(normalizedQuery)) !== null) { |
|
const param = match[1].toLowerCase() |
|
const value = match[2] |
|
const start = match.index |
|
const end = start + match[0].length |
|
|
|
usedIndices.push(start, end) |
|
lastIndex = end |
|
|
|
const values = parseValues(value) |
|
switch (param) { |
|
case 'hashtag': |
|
case 't': |
|
params.hashtag = values.length === 1 ? values[0] : values |
|
break |
|
case 'title': |
|
params.title = values.length === 1 ? values[0] : values |
|
break |
|
case 'subject': |
|
params.subject = values.length === 1 ? values[0] : values |
|
break |
|
case 'description': |
|
params.description = values.length === 1 ? values[0] : values |
|
break |
|
case 'author': |
|
params.author = values.length === 1 ? values[0] : values |
|
break |
|
case 'type': |
|
params.type = values.length === 1 ? values[0] : values |
|
break |
|
} |
|
} |
|
|
|
// Process unquoted parameters |
|
while ((match = unquotedPattern.exec(normalizedQuery)) !== null) { |
|
const start = match.index |
|
// Skip if already used by quoted pattern |
|
if (usedIndices.some((idx, i) => i % 2 === 0 && start >= idx && start <= usedIndices[i + 1])) { |
|
continue |
|
} |
|
|
|
const param = match[1].toLowerCase() |
|
const value = match[2] |
|
const end = start + match[0].length |
|
|
|
usedIndices.push(start, end) |
|
lastIndex = Math.max(lastIndex, end) |
|
|
|
switch (param) { |
|
case 'hashtag': |
|
case 't': |
|
if (!params.hashtag) { |
|
const values = parseValues(value) |
|
params.hashtag = values.length === 1 ? values[0] : values |
|
} |
|
break |
|
case 'title': |
|
if (!params.title) { |
|
const values = parseValues(value) |
|
params.title = values.length === 1 ? values[0] : values |
|
} |
|
break |
|
case 'subject': |
|
if (!params.subject) { |
|
const values = parseValues(value) |
|
params.subject = values.length === 1 ? values[0] : values |
|
} |
|
break |
|
case 'description': |
|
if (!params.description) { |
|
const values = parseValues(value) |
|
params.description = values.length === 1 ? values[0] : values |
|
} |
|
break |
|
case 'author': |
|
if (!params.author) { |
|
const values = parseValues(value) |
|
params.author = values.length === 1 ? values[0] : values |
|
} |
|
break |
|
case 'type': |
|
if (!params.type) { |
|
const values = parseValues(value) |
|
params.type = values.length === 1 ? values[0] : values |
|
} |
|
break |
|
} |
|
} |
|
|
|
// Process detected bare event IDs (those not used as parameters) |
|
// Note: Bare event IDs are left as plain text for standard search, not stored as filter params |
|
for (const detectedId of detectedEventIds) { |
|
const start = detectedId.start |
|
// Skip if already used by a parameter pattern |
|
if (usedIndices.some((idx, i) => i % 2 === 0 && start >= idx && start <= usedIndices[i + 1])) { |
|
continue |
|
} |
|
|
|
// Mark as used - but don't store in params.events, leave as plain text for standard search |
|
usedIndices.push(start, detectedId.end) |
|
} |
|
|
|
// Process detected bare pubkey IDs (those not used as parameters) |
|
// Note: Pubkey filters removed - not supported |
|
for (const detectedId of detectedPubkeyIds) { |
|
const start = detectedId.start |
|
// Skip if already used by a parameter pattern |
|
if (usedIndices.some((idx, i) => i % 2 === 0 && start >= idx && start <= usedIndices[i + 1])) { |
|
continue |
|
} |
|
|
|
// Mark as used - but don't store in params.pubkey, leave as plain text |
|
usedIndices.push(start, detectedId.end) |
|
} |
|
|
|
// Date searches removed - not supported |
|
|
|
// Extract plain text (everything not matched by patterns) |
|
usedIndices.sort((a, b) => a - b) |
|
let plainText = '' |
|
let textStart = 0 |
|
|
|
// Remove duplicate indices and merge overlapping ranges |
|
const ranges: Array<[number, number]> = [] |
|
for (let i = 0; i < usedIndices.length; i += 2) { |
|
const start = usedIndices[i] |
|
const end = usedIndices[i + 1] || usedIndices[i] |
|
ranges.push([start, end]) |
|
} |
|
|
|
// Sort and merge overlapping ranges |
|
ranges.sort((a, b) => a[0] - b[0]) |
|
const merged: Array<[number, number]> = [] |
|
for (const range of ranges) { |
|
if (merged.length === 0 || merged[merged.length - 1][1] < range[0]) { |
|
merged.push(range) |
|
} else { |
|
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], range[1]) |
|
} |
|
} |
|
|
|
// Extract plain text from gaps between used ranges |
|
for (const [start, end] of merged) { |
|
if (textStart < start) { |
|
const segment = normalizedQuery.substring(textStart, start).trim() |
|
if (segment) { |
|
plainText += (plainText ? ' ' : '') + segment |
|
} |
|
} |
|
textStart = Math.max(textStart, end) |
|
} |
|
|
|
// Add remaining text |
|
if (textStart < normalizedQuery.length) { |
|
const remaining = normalizedQuery.substring(textStart).trim() |
|
if (remaining) { |
|
plainText += (plainText ? ' ' : '') + remaining |
|
} |
|
} |
|
|
|
// If we have plain text and no other parameters, use it as d-tag |
|
if (plainText && !Object.keys(params).length) { |
|
params.dtag = normalizeToDTag(plainText) |
|
} else if (plainText) { |
|
// Plain text can also be used for d-tag even with other params |
|
params.dtag = normalizeToDTag(plainText) |
|
} |
|
|
|
return params |
|
} |
|
|
|
|