Files
bsky-social-app/src/screens/Search/components/AdvancedSearchDialog/utils.ts
T
2026-07-08 10:14:39 -07:00

359 lines
11 KiB
TypeScript

import {
extractSearchPostsParams,
tokenizeQuery,
} from '#/state/queries/search-posts-params'
import {type SearchFilters} from '#/screens/Search/searchParams'
export type RepliesFilter = 'all' | 'none' | 'only'
/**
* Media filter is mutually exclusive: a post matches at most one of these. It
* serializes into the separate media/video sibling params (only one is ever
* set at a time).
*/
export type MediaFilter = 'all' | 'media' | 'video'
/**
* Whether to limit results to authors the user follows. Serializes into the
* `following` sibling param ('following' -> following:true, anyone -> unset).
*/
export type FollowingFilter = 'anyone' | 'following'
export type FilterField = 'authors' | 'mentions' | 'domains' | 'urls' | 'tags'
export const FILTER_FIELDS: FilterField[] = [
'authors',
'mentions',
'domains',
'urls',
'tags',
]
/** Fields whose values are user handles, so they get handle typeahead. */
export const HANDLE_FIELDS = new Set<FilterField>(['authors', 'mentions'])
/**
* Whether a filter includes or excludes matching posts. Exclude is a v2-only
* capability; it serializes into the exclude* sibling params and is dropped on
* the search v1 path.
*/
export type FilterMode = 'include' | 'exclude'
export type AdvancedFilter = {
id: string
field: FilterField
mode: FilterMode
value: string
}
/**
* Monotonic counter for filter row ids, owned here so both this module and the
* component create rows through the same source. Imported `let` bindings are
* read-only, so callers must use makeFilter rather than incrementing directly.
*/
let nextFilterId = 0
/** Creates a new filter row with a unique id. */
export function makeFilter(
field: FilterField,
value: string = '',
mode: FilterMode = 'include',
): AdvancedFilter {
return {id: `filter-${nextFilterId++}`, field, mode, value}
}
/**
* A marker character users commonly type that is redundant with the field's
* stored form, e.g. "#cats" or "@alice"; stripped on serialize so we don't
* store "#cats" or "@alice" in the param value.
*/
const FIELD_MARKERS: Partial<Record<FilterField, string>> = {
authors: '@',
mentions: '@',
tags: '#',
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
function isValidDate(value: string): boolean {
if (!DATE_RE.test(value)) return false
const d = new Date(value + 'T00:00:00')
return !isNaN(d.getTime())
}
/**
* The dialog's full internal state. Free-text fields (query/exactPhrase/
* negatedWords) are derived from `q`; everything else comes from the structured
* filter params.
*/
export type DialogState = {
query: string
exactPhrase: string
negatedWords: string
language: string
replies: RepliesFilter
media: MediaFilter
following: FollowingFilter
since: string
until: string
filters: AdvancedFilter[]
}
const WHITESPACE_RE = /\s+/
/**
* Maps a filter field to the structured param key it serializes into, by mode.
* Include rows write the base param; exclude rows write the exclude* sibling.
*/
const FIELD_TO_PARAM: Record<
FilterMode,
Record<FilterField, keyof SearchFilters>
> = {
include: {
authors: 'author',
mentions: 'mentions',
domains: 'domain',
urls: 'url',
tags: 'tag',
},
exclude: {
authors: 'excludeAuthor',
mentions: 'excludeMentions',
domains: 'excludeDomain',
urls: 'excludeUrl',
tags: 'excludeTag',
},
}
/**
* A "simple" free-text word - no quotes. Only simple words round-trip cleanly
* through the negated-words field, so anything else is left in (or moved to)
* the main query rather than coerced into a `-word` token it can't represent.
*/
function isSimpleWord(word: string): boolean {
return word.length > 0 && !word.includes('"')
}
/**
* Parses the free-text portion of a query (`q`) into the dialog's text fields.
* `q` no longer carries structured operators - those arrive separately via
* `filters`. A quoted phrase populates "exact phrase" and -negated terms
* populate "none of these words" - but only when their contents are simple
* words. Anything that wouldn't round-trip (embedded quotes, a negated phrase
* like -"a b", etc.) is left verbatim in the main "all of these words" query
* text instead of parsed.
*/
function parseFreeText(raw: string): {
query: string
exactPhrase: string
negatedWords: string
} {
const queryParts: string[] = []
const negatedWords: string[] = []
let exactPhrase = ''
for (const token of tokenizeQuery(raw)) {
// "phrase" -> "exact phrase", only if it has no inner quote.
if (token.startsWith('"') && token.endsWith('"') && token.length > 1) {
const inner = token.slice(1, -1)
if (!inner.includes('"')) {
exactPhrase = inner
continue
}
}
// -word -> "none of these words", only if the negated word is simple.
if (token.startsWith('-') && token.length > 1 && !token.includes(':')) {
const word = token.slice(1)
if (isSimpleWord(word)) {
negatedWords.push(word)
continue
}
}
queryParts.push(token)
}
return {
query: queryParts.join(' '),
exactPhrase,
negatedWords: negatedWords.join(' '),
}
}
/**
* Joins two space-delimited value lists, dropping empties and duplicates while
* preserving order. Used to fold operators lifted from the query text into the
* matching structured filter values without clobbering either source.
*/
function mergeValues(a?: string, b?: string): string {
const seen = new Set<string>()
for (const part of `${a ?? ''} ${b ?? ''}`.split(WHITESPACE_RE)) {
if (part) seen.add(part)
}
return [...seen].join(' ')
}
/**
* Builds the full dialog state from the free-text `q` plus the structured
* filter params. Operators typed directly into the query box (e.g. `from:`,
* `domain:`, `since:`, `#tag`) are lifted out via extractSearchPostsParams and
* folded into the matching include filters/date fields, and removed from the
* free text - so the dialog shows them as structured rows rather than raw text.
*/
export function parseAdvancedSearch(
q: string,
filters: SearchFilters,
): DialogState {
/*
* Lift recognized operators out of the query text. Only include-mode fields
* can be expressed as operators; exclude rows come solely from filter params.
*/
const lifted = extractSearchPostsParams(q)
const freeText = parseFreeText(lifted.q)
const includeValues: Record<FilterField, string> = {
authors: mergeValues(filters.author, lifted.author),
mentions: mergeValues(filters.mentions, lifted.mentions),
domains: mergeValues(filters.domain, lifted.domain),
urls: mergeValues(filters.url, lifted.url),
tags: mergeValues(filters.tag, lifted.tag?.join(' ')),
}
const filterRows: AdvancedFilter[] = []
const MODES: FilterMode[] = ['include', 'exclude']
for (const field of FILTER_FIELDS) {
for (const mode of MODES) {
const value =
mode === 'include'
? includeValues[field]
: filters[FIELD_TO_PARAM[mode][field]]
if (value) {
/*
* Append a trailing space to handle fields so the typeahead (which
* matches the last token) stays closed until the user types. The space
* is ignored on serialize, which trims/splits on whitespace.
*/
filterRows.push(
makeFilter(
field,
HANDLE_FIELDS.has(field) ? `${value} ` : value,
mode,
),
)
}
}
}
let replies: RepliesFilter = 'all'
if (filters.replies === 'none') replies = 'none'
else if (filters.replies === 'only') replies = 'only'
let media: MediaFilter = 'all'
if (filters.media === 'true') media = 'media'
else if (filters.video === 'true') media = 'video'
const lang = filters.lang ?? lifted.lang ?? ''
const since = filters.since ?? lifted.since
const until = filters.until ?? lifted.until
return {
...freeText,
language: lang,
replies,
media,
following: filters.following === 'true' ? 'following' : 'anyone',
since: since && isValidDate(since) ? since : '',
until: until && isValidDate(until) ? until : '',
filters: filterRows,
}
}
/**
* Serializes the dialog state into the free-text `q` string plus the structured
* filter params. Free text (all/exact/none words) goes into `q`; everything
* else becomes a sibling param.
*/
export function serializeAdvancedSearch(state: {
query: string
exactPhrase: string
negatedWords: string
language: string
replies: RepliesFilter
media: MediaFilter
following: FollowingFilter
dateSince: string
dateSinceActive: boolean
dateUntil: string
dateUntilActive: boolean
filters: AdvancedFilter[]
}): {q: string; filters: SearchFilters} {
const parts: string[] = []
if (state.query.trim()) {
parts.push(state.query.trim())
}
/*
* "exact phrase" -> a quoted token, but only if it's a clean phrase with no
* embedded quotes. Otherwise it can't be safely wrapped, so pass it through
* to the query verbatim.
*/
const exactPhrase = state.exactPhrase.trim()
if (exactPhrase) {
parts.push(exactPhrase.includes('"') ? exactPhrase : `"${exactPhrase}"`)
}
/*
* "none of these words" -> -negated tokens, but only simple words get the `-`
* prefix. Anything unexpected passes through to the query verbatim.
*/
for (const word of state.negatedWords.trim().split(WHITESPACE_RE)) {
if (!word) continue
const bare = word.replace(/^-+/, '')
if (isSimpleWord(bare)) {
parts.push(`-${bare}`)
} else {
parts.push(word)
}
}
const filters: SearchFilters = {}
/*
* Each filter row's value is normalized (marker stripped) into space-joined
* values under its param key. The row's mode selects the include or exclude
* param. Multiple rows of the same field and mode merge into one param,
* deduping values across rows (and within a row) so e.g. two `author: alice`
* rows don't serialize to "alice alice".
*/
const valuesByKey = new Map<keyof SearchFilters, Set<string>>()
for (const filter of state.filters) {
const marker = FIELD_MARKERS[filter.field]
const values = filter.value
.trim()
.split(WHITESPACE_RE)
.filter(Boolean)
.map(v => (marker && v.startsWith(marker) ? v.slice(1) : v))
.filter(Boolean)
if (values.length) {
const key = FIELD_TO_PARAM[filter.mode][filter.field]
const set = valuesByKey.get(key) ?? new Set<string>()
for (const value of values) set.add(value)
valuesByKey.set(key, set)
}
}
for (const [key, set] of valuesByKey) {
filters[key] = [...set].join(' ')
}
if (state.language) filters.lang = state.language
if (state.dateSinceActive && state.dateSince) filters.since = state.dateSince
if (state.dateUntilActive && state.dateUntil) filters.until = state.dateUntil
if (state.replies === 'none') filters.replies = 'none'
if (state.replies === 'only') filters.replies = 'only'
if (state.media === 'media') filters.media = 'true'
else if (state.media === 'video') filters.video = 'true'
if (state.following === 'following') filters.following = 'true'
return {q: parts.join(' '), filters}
}