Add button-driven search filters
This commit is contained in:
@@ -4,6 +4,19 @@ import { ListForm } from './list-form'
|
||||
import { SearchForm } from './search-form'
|
||||
import { UserForm } from './user-form'
|
||||
|
||||
const searchValues = {
|
||||
q: '',
|
||||
from: '',
|
||||
since: '',
|
||||
until: '',
|
||||
lang: 'all' as const,
|
||||
content: 'all' as const,
|
||||
excludeReplies: false,
|
||||
excludeReposts: false,
|
||||
product: 'Latest' as const,
|
||||
following: false,
|
||||
}
|
||||
|
||||
describe('UserForm', () => {
|
||||
it('submits only after the user enters a target', () => {
|
||||
const onSubmit = vi.fn()
|
||||
@@ -58,40 +71,82 @@ describe('ListForm', () => {
|
||||
|
||||
describe('SearchForm', () => {
|
||||
it('uses a labelled grouping for ranking controls', () => {
|
||||
render(
|
||||
<SearchForm
|
||||
initialQuery=""
|
||||
initialProduct="Latest"
|
||||
initialFollowing={false}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
render(<SearchForm initialValues={searchValues} onSubmit={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('group', { name: '並び順' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('submits the selected ranking and follows filter', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(
|
||||
<SearchForm
|
||||
initialQuery=""
|
||||
initialProduct="Latest"
|
||||
initialFollowing={false}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
render(<SearchForm initialValues={searchValues} onSubmit={onSubmit} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('検索語'), {
|
||||
target: { value: 'AI lang:ja' },
|
||||
target: { value: ' AI lang:ja ' },
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('人気順'))
|
||||
fireEvent.click(screen.getByLabelText('フォロー中のみ'))
|
||||
expect(screen.getByText('AI lang:ja filter:follows')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '検索' }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
q: 'AI lang:ja',
|
||||
from: '',
|
||||
since: '',
|
||||
until: '',
|
||||
lang: 'all',
|
||||
content: 'all',
|
||||
excludeReplies: false,
|
||||
excludeReposts: false,
|
||||
product: 'Top',
|
||||
following: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('submits conditions selected with the filter buttons', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<SearchForm initialValues={searchValues} onSubmit={onSubmit} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('検索語'), {
|
||||
target: { value: 'TypeScript' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '投稿者' }))
|
||||
fireEvent.change(screen.getByLabelText('投稿者'), {
|
||||
target: { value: '@tan_stack' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '言語' }))
|
||||
fireEvent.click(screen.getByLabelText('日本語'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '投稿の種類' }))
|
||||
fireEvent.click(screen.getByLabelText('画像'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '除外' }))
|
||||
fireEvent.click(screen.getByLabelText('返信'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '検索' }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
...searchValues,
|
||||
q: 'TypeScript',
|
||||
from: '@tan_stack',
|
||||
lang: 'ja',
|
||||
content: 'images',
|
||||
excludeReplies: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('removes only the selected condition from its active chip', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(
|
||||
<SearchForm
|
||||
initialValues={{ ...searchValues, q: 'AI', from: 'yuta', lang: 'ja' }}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '投稿者 yuta を解除' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '検索' }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
...searchValues,
|
||||
q: 'AI',
|
||||
lang: 'ja',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,37 +1,57 @@
|
||||
import type { SearchProduct } from '@yuta/bird'
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { buildSearchQuery } from '../inputs'
|
||||
import { type AdvancedSearchQuery, buildFilteredSearchQuery } from '../inputs'
|
||||
|
||||
type SearchValues = {
|
||||
q: string
|
||||
export type SearchValues = AdvancedSearchQuery & {
|
||||
product: SearchProduct
|
||||
following: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
initialQuery: string
|
||||
initialProduct: SearchProduct
|
||||
initialFollowing: boolean
|
||||
initialValues: SearchValues
|
||||
onSubmit: (values: SearchValues) => void
|
||||
}
|
||||
|
||||
export function SearchForm({
|
||||
initialQuery,
|
||||
initialProduct,
|
||||
initialFollowing,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const [q, setQuery] = useState(initialQuery)
|
||||
const [product, setProduct] = useState(initialProduct)
|
||||
const [following, setFollowing] = useState(initialFollowing)
|
||||
type FilterName = 'author' | 'period' | 'language' | 'content' | 'exclude'
|
||||
|
||||
const languageLabels = { all: 'すべて', ja: '日本語', en: '英語' } as const
|
||||
const contentLabels = {
|
||||
all: 'すべて',
|
||||
images: '画像',
|
||||
videos: '動画',
|
||||
links: 'リンク',
|
||||
} as const
|
||||
|
||||
function localDate(daysAgo: number): string {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - daysAgo)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
export function SearchForm({ initialValues, onSubmit }: Props) {
|
||||
const [values, setValues] = useState(initialValues)
|
||||
const [openFilter, setOpenFilter] = useState<FilterName>()
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const update = <Key extends keyof SearchValues>(
|
||||
key: Key,
|
||||
value: SearchValues[Key],
|
||||
) => setValues((current) => ({ ...current, [key]: value }))
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
const normalized = {
|
||||
...values,
|
||||
q: values.q.trim(),
|
||||
from: values.from.trim(),
|
||||
}
|
||||
try {
|
||||
const query = buildSearchQuery(q, false)
|
||||
buildFilteredSearchQuery(normalized, normalized.following)
|
||||
setError('')
|
||||
onSubmit({ q: query, product, following })
|
||||
onSubmit(normalized)
|
||||
} catch (cause) {
|
||||
setError(
|
||||
cause instanceof Error ? cause.message : '入力内容を確認してください。',
|
||||
@@ -39,33 +59,337 @@ export function SearchForm({
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFilter = (filter: FilterName) => {
|
||||
setOpenFilter((current) => (current === filter ? undefined : filter))
|
||||
}
|
||||
|
||||
const setRecentPeriod = (days: number) => {
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
since: localDate(days - 1),
|
||||
until: '',
|
||||
}))
|
||||
}
|
||||
|
||||
let preview = '検索語または投稿者を入力すると表示されます。'
|
||||
try {
|
||||
preview = buildFilteredSearchQuery(values, values.following)
|
||||
} catch {
|
||||
// The submit error gives the actionable message; the preview stays quiet.
|
||||
}
|
||||
|
||||
const filterButtons: Array<{
|
||||
name: FilterName
|
||||
label: string
|
||||
active: boolean
|
||||
}> = [
|
||||
{ name: 'author', label: '投稿者', active: Boolean(values.from) },
|
||||
{
|
||||
name: 'period',
|
||||
label: '期間',
|
||||
active: Boolean(values.since || values.until),
|
||||
},
|
||||
{ name: 'language', label: '言語', active: values.lang !== 'all' },
|
||||
{
|
||||
name: 'content',
|
||||
label: '投稿の種類',
|
||||
active: values.content !== 'all',
|
||||
},
|
||||
{
|
||||
name: 'exclude',
|
||||
label: '除外',
|
||||
active: values.excludeReplies || values.excludeReposts,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<form className="intent-form" onSubmit={submit}>
|
||||
<label className="field">
|
||||
<span>検索語</span>
|
||||
<form className="intent-form search-form" onSubmit={submit}>
|
||||
<div className="field search-query">
|
||||
<label htmlFor="search-query">検索語</label>
|
||||
<input
|
||||
aria-describedby="search-query-help"
|
||||
autoComplete="off"
|
||||
aria-describedby={error ? 'query-error' : undefined}
|
||||
aria-invalid={Boolean(error)}
|
||||
id="search-query"
|
||||
name="q"
|
||||
value={q}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder="検索語や高度な検索演算子"
|
||||
value={values.q}
|
||||
onChange={(event) => update('q', event.currentTarget.value)}
|
||||
placeholder="キーワードや高度な検索式"
|
||||
/>
|
||||
{error ? (
|
||||
<span className="field-error" id="query-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
<span className="field-help" id="search-query-help">
|
||||
演算子を直接入力することもできます。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<fieldset className="search-filter-buttons">
|
||||
<legend>条件を追加</legend>
|
||||
{filterButtons.map((filter) => (
|
||||
<button
|
||||
aria-controls={`search-filter-${filter.name}`}
|
||||
aria-expanded={openFilter === filter.name}
|
||||
className="filter-button"
|
||||
data-active={filter.active || undefined}
|
||||
key={filter.name}
|
||||
onClick={() => toggleFilter(filter.name)}
|
||||
type="button"
|
||||
>
|
||||
{filter.label}
|
||||
{filter.active ? <span aria-hidden="true">●</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
{openFilter ? (
|
||||
<fieldset
|
||||
className="search-filter-panel"
|
||||
id={`search-filter-${openFilter}`}
|
||||
>
|
||||
<legend>
|
||||
{filterButtons.find((item) => item.name === openFilter)?.label}
|
||||
の条件
|
||||
</legend>
|
||||
{openFilter === 'author' ? (
|
||||
<div className="field compact-field">
|
||||
<label htmlFor="search-author">投稿者</label>
|
||||
<input
|
||||
aria-describedby="search-author-help"
|
||||
autoComplete="off"
|
||||
id="search-author"
|
||||
name="from"
|
||||
onChange={(event) => update('from', event.currentTarget.value)}
|
||||
placeholder="@handle"
|
||||
value={values.from}
|
||||
/>
|
||||
<span className="field-help" id="search-author-help">
|
||||
@は省略できます。
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{openFilter === 'period' ? (
|
||||
<>
|
||||
<fieldset className="segmented search-filter-options">
|
||||
<legend>すぐ選ぶ</legend>
|
||||
<button onClick={() => setRecentPeriod(1)} type="button">
|
||||
今日
|
||||
</button>
|
||||
<button onClick={() => setRecentPeriod(7)} type="button">
|
||||
過去7日
|
||||
</button>
|
||||
<button onClick={() => setRecentPeriod(30)} type="button">
|
||||
過去30日
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
since: '',
|
||||
until: '',
|
||||
}))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
すべて
|
||||
</button>
|
||||
</fieldset>
|
||||
<div className="date-fields">
|
||||
<div className="field compact-field">
|
||||
<label htmlFor="search-since">開始日</label>
|
||||
<input
|
||||
id="search-since"
|
||||
name="since"
|
||||
onChange={(event) =>
|
||||
update('since', event.currentTarget.value)
|
||||
}
|
||||
type="date"
|
||||
value={values.since}
|
||||
/>
|
||||
<span className="field-help" />
|
||||
</div>
|
||||
<div className="field compact-field">
|
||||
<label htmlFor="search-until">
|
||||
終了日(この日は含まない)
|
||||
</label>
|
||||
<input
|
||||
id="search-until"
|
||||
name="until"
|
||||
onChange={(event) =>
|
||||
update('until', event.currentTarget.value)
|
||||
}
|
||||
type="date"
|
||||
value={values.until}
|
||||
/>
|
||||
<span className="field-help" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{openFilter === 'language' ? (
|
||||
<fieldset className="segmented search-filter-options">
|
||||
<legend>言語</legend>
|
||||
{(
|
||||
Object.keys(languageLabels) as Array<
|
||||
keyof typeof languageLabels
|
||||
>
|
||||
).map((value) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
checked={values.lang === value}
|
||||
name="lang"
|
||||
onChange={() => update('lang', value)}
|
||||
type="radio"
|
||||
value={value}
|
||||
/>
|
||||
{languageLabels[value]}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{openFilter === 'content' ? (
|
||||
<fieldset className="segmented search-filter-options">
|
||||
<legend>投稿の種類</legend>
|
||||
{(
|
||||
Object.keys(contentLabels) as Array<keyof typeof contentLabels>
|
||||
).map((value) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
checked={values.content === value}
|
||||
name="content"
|
||||
onChange={() => update('content', value)}
|
||||
type="radio"
|
||||
value={value}
|
||||
/>
|
||||
{contentLabels[value]}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{openFilter === 'exclude' ? (
|
||||
<fieldset className="search-exclusions">
|
||||
<legend>検索結果から除外</legend>
|
||||
<label className="check">
|
||||
<input
|
||||
checked={values.excludeReplies}
|
||||
onChange={(event) =>
|
||||
update('excludeReplies', event.currentTarget.checked)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
返信
|
||||
</label>
|
||||
<label className="check">
|
||||
<input
|
||||
checked={values.excludeReposts}
|
||||
onChange={(event) =>
|
||||
update('excludeReposts', event.currentTarget.checked)
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
リポスト
|
||||
</label>
|
||||
</fieldset>
|
||||
) : null}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
<ul aria-label="選択中の条件" className="active-filters">
|
||||
{values.from ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label={`投稿者 ${values.from} を解除`}
|
||||
onClick={() => update('from', '')}
|
||||
type="button"
|
||||
>
|
||||
{values.from.startsWith('@') ? values.from : `@${values.from}`}{' '}
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</label>
|
||||
<fieldset className="segmented">
|
||||
{values.since || values.until ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label="期間を解除"
|
||||
onClick={() =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
since: '',
|
||||
until: '',
|
||||
}))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{values.since || '指定なし'} → {values.until || '現在'}{' '}
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{values.lang !== 'all' ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label={`言語 ${languageLabels[values.lang]} を解除`}
|
||||
onClick={() => update('lang', 'all')}
|
||||
type="button"
|
||||
>
|
||||
{languageLabels[values.lang]} <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{values.content !== 'all' ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label={`投稿の種類 ${contentLabels[values.content]} を解除`}
|
||||
onClick={() => update('content', 'all')}
|
||||
type="button"
|
||||
>
|
||||
{contentLabels[values.content]} <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{values.excludeReplies ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label="返信の除外を解除"
|
||||
onClick={() => update('excludeReplies', false)}
|
||||
type="button"
|
||||
>
|
||||
返信を除外 <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{values.excludeReposts ? (
|
||||
<li>
|
||||
<button
|
||||
aria-label="リポストの除外を解除"
|
||||
onClick={() => update('excludeReposts', false)}
|
||||
type="button"
|
||||
>
|
||||
リポストを除外 <span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
|
||||
<details className="search-query-preview">
|
||||
<summary>検索式を確認</summary>
|
||||
<code>{preview}</code>
|
||||
</details>
|
||||
|
||||
{error ? (
|
||||
<p className="field-error search-error" id="query-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<fieldset className="segmented search-ranking">
|
||||
<legend>並び順</legend>
|
||||
{(['Top', 'Latest'] as const).map((value) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
checked={product === value}
|
||||
checked={values.product === value}
|
||||
name="product"
|
||||
onChange={() => setProduct(value)}
|
||||
onChange={() => update('product', value)}
|
||||
type="radio"
|
||||
value={value}
|
||||
/>
|
||||
@@ -73,15 +397,17 @@ export function SearchForm({
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<label className="check">
|
||||
<label className="check search-following">
|
||||
<input
|
||||
checked={following}
|
||||
onChange={(event) => setFollowing(event.currentTarget.checked)}
|
||||
checked={values.following}
|
||||
onChange={(event) => update('following', event.currentTarget.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
フォロー中のみ
|
||||
</label>
|
||||
<button type="submit">検索</button>
|
||||
<button className="search-submit" type="submit">
|
||||
検索
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildAdvancedSearchQuery,
|
||||
buildFilteredSearchQuery,
|
||||
buildSearchQuery,
|
||||
normalizeListTarget,
|
||||
normalizeTweetId,
|
||||
normalizeUserTarget,
|
||||
postSearchRouteSchema,
|
||||
threadPageInputSchema,
|
||||
} from './inputs'
|
||||
|
||||
@@ -108,3 +111,134 @@ describe('buildSearchQuery', () => {
|
||||
expect(() => buildSearchQuery(' ', false)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAdvancedSearchQuery', () => {
|
||||
it('builds a query from button-friendly filters', () => {
|
||||
expect(
|
||||
buildAdvancedSearchQuery({
|
||||
q: 'TypeScript',
|
||||
from: '@tan_stack',
|
||||
since: '2026-08-04',
|
||||
until: '2026-08-11',
|
||||
lang: 'ja',
|
||||
content: 'images',
|
||||
excludeReplies: true,
|
||||
excludeReposts: true,
|
||||
}),
|
||||
).toBe(
|
||||
'TypeScript from:tan_stack since:2026-08-04 until:2026-08-11 lang:ja filter:images -filter:replies -filter:retweets',
|
||||
)
|
||||
})
|
||||
|
||||
it('allows an author search without keywords', () => {
|
||||
expect(
|
||||
buildAdvancedSearchQuery({
|
||||
q: '',
|
||||
from: 'tan_stack',
|
||||
since: '',
|
||||
until: '',
|
||||
lang: 'all',
|
||||
content: 'all',
|
||||
excludeReplies: false,
|
||||
excludeReposts: false,
|
||||
}),
|
||||
).toBe('from:tan_stack')
|
||||
})
|
||||
|
||||
it('rejects filters that have no keyword or author', () => {
|
||||
expect(() =>
|
||||
buildAdvancedSearchQuery({
|
||||
q: '',
|
||||
from: '',
|
||||
since: '2026-08-04',
|
||||
until: '',
|
||||
lang: 'ja',
|
||||
content: 'all',
|
||||
excludeReplies: false,
|
||||
excludeReposts: false,
|
||||
}),
|
||||
).toThrow('検索語または投稿者を入力してください。')
|
||||
})
|
||||
|
||||
it('rejects an invalid author and reversed dates', () => {
|
||||
const base = {
|
||||
q: 'TypeScript',
|
||||
from: '',
|
||||
since: '',
|
||||
until: '',
|
||||
lang: 'all' as const,
|
||||
content: 'all' as const,
|
||||
excludeReplies: false,
|
||||
excludeReposts: false,
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
buildAdvancedSearchQuery({ ...base, from: 'not-valid!' }),
|
||||
).toThrow('投稿者のハンドルを確認してください。')
|
||||
expect(() =>
|
||||
buildAdvancedSearchQuery({
|
||||
...base,
|
||||
since: '2026-08-11',
|
||||
until: '2026-08-10',
|
||||
}),
|
||||
).toThrow('終了日は開始日より後の日付を選んでください。')
|
||||
})
|
||||
|
||||
it('rejects nonexistent dates and a compiled query over 512 characters', () => {
|
||||
const base = {
|
||||
q: 'TypeScript',
|
||||
from: '',
|
||||
since: '',
|
||||
until: '',
|
||||
lang: 'all' as const,
|
||||
content: 'all' as const,
|
||||
excludeReplies: false,
|
||||
excludeReposts: false,
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
buildAdvancedSearchQuery({ ...base, since: '2026-02-30' }),
|
||||
).toThrow('開始日を確認してください。')
|
||||
expect(() =>
|
||||
buildAdvancedSearchQuery({ ...base, q: 'a'.repeat(510), lang: 'ja' }),
|
||||
).toThrow('検索語は 512 文字以内で入力してください。')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildFilteredSearchQuery', () => {
|
||||
it('includes the follows filter in the final query and enforces its length', () => {
|
||||
const input = {
|
||||
q: 'TypeScript',
|
||||
from: '',
|
||||
since: '',
|
||||
until: '',
|
||||
lang: 'ja' as const,
|
||||
content: 'all' as const,
|
||||
excludeReplies: false,
|
||||
excludeReposts: false,
|
||||
}
|
||||
|
||||
expect(buildFilteredSearchQuery(input, true)).toBe(
|
||||
'TypeScript lang:ja filter:follows',
|
||||
)
|
||||
expect(() =>
|
||||
buildFilteredSearchQuery({ ...input, q: 'a'.repeat(498) }, true),
|
||||
).toThrow('検索語は 512 文字以内で入力してください。')
|
||||
})
|
||||
})
|
||||
|
||||
describe('postSearchRouteSchema', () => {
|
||||
it('keeps omitted filter parameters optional', () => {
|
||||
expect(
|
||||
postSearchRouteSchema.parse({
|
||||
q: 'AI',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
}),
|
||||
).toEqual({
|
||||
q: 'AI',
|
||||
product: 'Latest',
|
||||
following: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
||||
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
|
||||
const TWEET_ID = /^\d{1,32}$/
|
||||
const LIST_ID = /^\d{1,32}$/
|
||||
@@ -11,6 +12,23 @@ const tweetIdSchema = z
|
||||
|
||||
export class InputError extends Error {}
|
||||
|
||||
const searchLanguageSchema = z.enum(['all', 'ja', 'en'])
|
||||
const searchContentSchema = z.enum(['all', 'images', 'videos', 'links'])
|
||||
|
||||
type SearchLanguage = z.infer<typeof searchLanguageSchema>
|
||||
type SearchContent = z.infer<typeof searchContentSchema>
|
||||
|
||||
export type AdvancedSearchQuery = {
|
||||
q: string
|
||||
from: string
|
||||
since: string
|
||||
until: string
|
||||
lang: SearchLanguage
|
||||
content: SearchContent
|
||||
excludeReplies: boolean
|
||||
excludeReposts: boolean
|
||||
}
|
||||
|
||||
export const threadPageInputSchema = z.union([
|
||||
z.object({
|
||||
tweetId: tweetIdSchema,
|
||||
@@ -45,10 +63,87 @@ export const searchPageInputSchema = z.object({
|
||||
|
||||
export const postSearchRouteSchema = z.object({
|
||||
q: z.string().catch(''),
|
||||
from: z
|
||||
.string()
|
||||
.refine((value) => !value || HANDLE.test(value.replace(/^@/, '')))
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
since: z
|
||||
.string()
|
||||
.refine((value) => !value || isValidDate(value))
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
until: z
|
||||
.string()
|
||||
.refine((value) => !value || isValidDate(value))
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
lang: searchLanguageSchema.optional().catch(undefined),
|
||||
content: searchContentSchema.optional().catch(undefined),
|
||||
excludeReplies: z.boolean().optional().catch(undefined),
|
||||
excludeReposts: z.boolean().optional().catch(undefined),
|
||||
product: z.enum(['Top', 'Latest']).catch('Latest'),
|
||||
following: z.boolean().catch(false),
|
||||
})
|
||||
|
||||
function isValidDate(value: string): boolean {
|
||||
if (!ISO_DATE.test(value)) return false
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
const date = new Date(Date.UTC(year ?? 0, (month ?? 0) - 1, day))
|
||||
return (
|
||||
date.getUTCFullYear() === year &&
|
||||
date.getUTCMonth() + 1 === month &&
|
||||
date.getUTCDate() === day
|
||||
)
|
||||
}
|
||||
|
||||
function requireDate(value: string, label: string): string {
|
||||
if (!isValidDate(value)) {
|
||||
throw new InputError(`${label}を確認してください。`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeSearchAuthor(raw: string): string {
|
||||
const value = raw.trim().replace(/^@/, '')
|
||||
if (!HANDLE.test(value)) {
|
||||
throw new InputError('投稿者のハンドルを確認してください。')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function buildAdvancedSearchQuery(input: AdvancedSearchQuery): string {
|
||||
const query = input.q.trim()
|
||||
const author = input.from.trim()
|
||||
if (!query && !author) {
|
||||
throw new InputError('検索語または投稿者を入力してください。')
|
||||
}
|
||||
|
||||
const parts = [query]
|
||||
if (author) parts.push(`from:${normalizeSearchAuthor(author)}`)
|
||||
|
||||
const since = input.since ? requireDate(input.since, '開始日') : ''
|
||||
const until = input.until ? requireDate(input.until, '終了日') : ''
|
||||
if (since && until && since >= until) {
|
||||
throw new InputError('終了日は開始日より後の日付を選んでください。')
|
||||
}
|
||||
if (since) parts.push(`since:${since}`)
|
||||
if (until) parts.push(`until:${until}`)
|
||||
if (input.lang !== 'all') parts.push(`lang:${input.lang}`)
|
||||
if (input.content !== 'all') parts.push(`filter:${input.content}`)
|
||||
if (input.excludeReplies) parts.push('-filter:replies')
|
||||
if (input.excludeReposts) parts.push('-filter:retweets')
|
||||
|
||||
return buildSearchQuery(parts.filter(Boolean).join(' '), false)
|
||||
}
|
||||
|
||||
export function buildFilteredSearchQuery(
|
||||
input: AdvancedSearchQuery,
|
||||
following: boolean,
|
||||
): string {
|
||||
return buildSearchQuery(buildAdvancedSearchQuery(input), following)
|
||||
}
|
||||
|
||||
export function normalizeTweetId(raw: string): string {
|
||||
const value = raw.trim()
|
||||
if (!TWEET_ID.test(value)) {
|
||||
@@ -129,8 +224,10 @@ export function buildSearchQuery(raw: string, following: boolean): string {
|
||||
if (!query) {
|
||||
throw new InputError('検索語を入力してください。')
|
||||
}
|
||||
if (query.length > 512) {
|
||||
const finalQuery =
|
||||
following && !FOLLOWS.test(query) ? `${query} filter:follows` : query
|
||||
if (finalQuery.length > 512) {
|
||||
throw new InputError('検索語は 512 文字以内で入力してください。')
|
||||
}
|
||||
return following && !FOLLOWS.test(query) ? `${query} filter:follows` : query
|
||||
return finalQuery
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user