feat: 支持默认搜索自动识别通配符
This commit is contained in:
@@ -170,6 +170,7 @@
|
||||
- [x] 实现简单现代并适配移动端的单页搜索界面
|
||||
- [x] 接入搜索排序分页详情内容变体和磁力链接复制
|
||||
- [x] 实现名称别名和文件路径的有限状态自动机正则搜索
|
||||
- [x] 在普通搜索中自动识别通配符并保留独立正则开关
|
||||
- [x] 实现种子详情文件列表后端分页并限制浏览器单页节点数量
|
||||
- [x] 保留种子详情顶部结构并使用 reka-ui 数字分页重构文件条目
|
||||
- [x] 支持用户选择并持久化文件列表每页数量
|
||||
|
||||
@@ -152,9 +152,10 @@ GET /health
|
||||
GET /ready
|
||||
GET /stats
|
||||
GET /search?q=ubuntu&offset=0&limit=20
|
||||
GET /search?q=%2A.iso
|
||||
GET /search?q=&min_size=1048576&max_size=10737418240&extension=mkv
|
||||
GET /search?q=流浪地球&min_files=1&availability=active&heat=hot&sort=heat
|
||||
GET /search?q=%5ES%5Cd%7B2%7DE%5Cd%7B2%7D®ex=true
|
||||
GET /search?q=%5ES%5Cd%7B2%7DE%5Cd%7B2%7D&mode=regex
|
||||
GET /contents/{content_key}?offset=0&limit=20
|
||||
GET /torrents/{infohash}?file_offset=0&file_limit=100
|
||||
```
|
||||
@@ -163,7 +164,9 @@ GET /torrents/{infohash}?file_offset=0&file_limit=100
|
||||
|
||||
搜索支持中文英文数字和文件名片段匹配
|
||||
|
||||
设置 `regex=true` 后查询文本作为不区分大小写的正则表达式匹配名称 别名和文件路径 正则最长 256 字节并由 Tantivy 有限状态自动机执行
|
||||
默认搜索会自动识别不区分大小写的通配符并匹配名称 别名和文件路径 `*` 表示任意长度字符 `?` 表示一个字符 例如 `*.iso` 匹配所有以 `.iso` 结尾的已索引名称或文件路径 不含通配符时保持普通关键词和片段搜索
|
||||
|
||||
设置 `mode=regex` 后查询文本作为不区分大小写的正则表达式匹配名称 别名和文件路径 通配符和正则最长 256 字节并由 Tantivy 有限状态自动机执行
|
||||
|
||||
过滤参数还包括 `min_files` `max_files` `first_seen_after` `first_seen_before` `last_seen_after` `last_seen_before` `availability` 和 `heat`
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ use axum::{
|
||||
};
|
||||
use dht_search::{
|
||||
domain::{InfoHash, MetadataRejectionReason},
|
||||
search::{SearchOptions, SearchPage, SearchSort},
|
||||
search::{SearchMode, SearchOptions, SearchPage, SearchSort},
|
||||
storage::VerificationPriority,
|
||||
};
|
||||
|
||||
@@ -118,8 +118,10 @@ pub(crate) async fn search(
|
||||
if request.q.len() > 512 {
|
||||
return Err(ApiError::bad_request("查询文本不能超过 512 字节"));
|
||||
}
|
||||
if request.regex && request.q.len() > 256 {
|
||||
return Err(ApiError::bad_request("正则表达式不能超过 256 字节"));
|
||||
if (request.mode != SearchMode::Text || request.q.contains('*') || request.q.contains('?'))
|
||||
&& request.q.len() > 256
|
||||
{
|
||||
return Err(ApiError::bad_request("通配符或正则表达式不能超过 256 字节"));
|
||||
}
|
||||
validate_range(request.min_size, request.max_size, "min_size", "max_size")?;
|
||||
validate_range(
|
||||
@@ -141,7 +143,7 @@ pub(crate) async fn search(
|
||||
"last_seen_before",
|
||||
)?;
|
||||
let mut query = request.q;
|
||||
let content_key = if !request.regex {
|
||||
let content_key = if request.mode == SearchMode::Text {
|
||||
InfoHash::from_str(query.trim()).ok()
|
||||
} else {
|
||||
None
|
||||
@@ -169,7 +171,7 @@ pub(crate) async fn search(
|
||||
let page = tokio::task::spawn_blocking(move || {
|
||||
state.search.search_with(SearchOptions {
|
||||
query,
|
||||
regex: request.regex,
|
||||
mode: request.mode,
|
||||
offset: request.offset,
|
||||
limit: request.limit,
|
||||
min_size: request.min_size,
|
||||
|
||||
@@ -172,7 +172,7 @@ mod tests {
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/search?q=example.%2Amovie®ex=true")
|
||||
.uri("/search?q=example.%2Amovie&mode=regex")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -188,7 +188,23 @@ mod tests {
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/search?q=%5B®ex=true")
|
||||
.uri("/search?q=%2A.mkv")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(json["total"], 1);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/search?q=%5B&mode=regex")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use dht_search::{
|
||||
domain::{AvailabilityStatus, HeatLevel},
|
||||
search::SearchSort,
|
||||
search::{SearchMode, SearchSort},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -15,7 +15,7 @@ pub(crate) struct SearchRequest {
|
||||
#[serde(default)]
|
||||
pub(crate) q: String,
|
||||
#[serde(default)]
|
||||
pub(crate) regex: bool,
|
||||
pub(crate) mode: SearchMode,
|
||||
#[serde(default)]
|
||||
pub(crate) offset: usize,
|
||||
#[serde(default = "default_limit")]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::error::Error;
|
||||
|
||||
use dht_search::search::{SearchOptions, SearchSort};
|
||||
use dht_search::search::{SearchMode, SearchOptions, SearchSort};
|
||||
|
||||
use super::dataset::generate_record;
|
||||
|
||||
@@ -56,7 +56,7 @@ pub(crate) fn query_cases(
|
||||
name: "有限状态正则",
|
||||
options: SearchOptions {
|
||||
query: "ubuntu.*desktop".into(),
|
||||
regex: true,
|
||||
mode: SearchMode::Regex,
|
||||
limit: 20,
|
||||
..SearchOptions::default()
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ use unicode_normalization::UnicodeNormalization;
|
||||
use super::{
|
||||
SearchError,
|
||||
document::availability_number,
|
||||
query::{SearchOptions, SearchSort},
|
||||
query::{SearchMode, SearchOptions, SearchSort},
|
||||
schema::SearchFields,
|
||||
};
|
||||
|
||||
@@ -29,10 +29,14 @@ pub(crate) fn prepare(
|
||||
let query_text = options.query.trim();
|
||||
if query_text.is_empty() || query_text == "*" {
|
||||
clauses.push(Box::new(AllQuery));
|
||||
} else if options.regex {
|
||||
clauses.push(regex_query(query_text, fields)?);
|
||||
} else {
|
||||
clauses.push(text_query(query_text, fields));
|
||||
clauses.push(match options.mode {
|
||||
SearchMode::Text if has_wildcard_syntax(query_text) => {
|
||||
wildcard_query(query_text, fields)?
|
||||
}
|
||||
SearchMode::Text => text_query(query_text, fields),
|
||||
SearchMode::Regex => regex_query(query_text, fields)?,
|
||||
});
|
||||
}
|
||||
if let Some(content_key) = options.content_key {
|
||||
clauses.push(Box::new(TermQuery::new(
|
||||
@@ -131,6 +135,50 @@ fn regex_query(pattern: &str, fields: SearchFields) -> Result<Box<dyn Query>, Se
|
||||
)?))
|
||||
}
|
||||
|
||||
fn wildcard_query(pattern: &str, fields: SearchFields) -> Result<Box<dyn Query>, SearchError> {
|
||||
Ok(Box::new(RegexQuery::from_pattern(
|
||||
&wildcard_pattern(pattern),
|
||||
fields.regex_text,
|
||||
)?))
|
||||
}
|
||||
|
||||
fn has_wildcard_syntax(pattern: &str) -> bool {
|
||||
pattern.contains('*') || pattern.contains('?')
|
||||
}
|
||||
|
||||
fn wildcard_pattern(pattern: &str) -> String {
|
||||
let pattern = normalize_text(pattern);
|
||||
let mut regex = String::with_capacity(pattern.len());
|
||||
let mut escaped = false;
|
||||
for character in pattern.chars() {
|
||||
if escaped {
|
||||
push_regex_literal(&mut regex, character);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
match character {
|
||||
'\\' => escaped = true,
|
||||
'*' => regex.push_str(".*"),
|
||||
'?' => regex.push('.'),
|
||||
literal => push_regex_literal(&mut regex, literal),
|
||||
}
|
||||
}
|
||||
if escaped {
|
||||
push_regex_literal(&mut regex, '\\');
|
||||
}
|
||||
regex
|
||||
}
|
||||
|
||||
fn push_regex_literal(regex: &mut String, character: char) {
|
||||
if matches!(
|
||||
character,
|
||||
'\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$'
|
||||
) {
|
||||
regex.push('\\');
|
||||
}
|
||||
regex.push(character);
|
||||
}
|
||||
|
||||
fn text_query(query: &str, fields: SearchFields) -> Box<dyn Query> {
|
||||
let normalized = normalize_text(query.trim());
|
||||
if normalized.len() == 40 && normalized.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
@@ -220,3 +268,18 @@ fn add_range(
|
||||
.unwrap_or(Bound::Unbounded);
|
||||
clauses.push(Box::new(RangeQuery::new(lower, upper)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{has_wildcard_syntax, wildcard_pattern};
|
||||
|
||||
#[test]
|
||||
fn wildcard_conversion_is_case_insensitive_and_escapes_regex_syntax() {
|
||||
assert_eq!(wildcard_pattern("*.ISO"), r".*\.iso");
|
||||
assert_eq!(wildcard_pattern("file?.[ch]"), r"file.\.\[ch\]");
|
||||
assert_eq!(wildcard_pattern(r"literal\*name"), r"literal\*name");
|
||||
assert!(has_wildcard_syntax("*.iso"));
|
||||
assert!(has_wildcard_syntax("file?.mkv"));
|
||||
assert!(has_wildcard_syntax(r"literal\*name"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +271,7 @@ mod tests {
|
||||
use crate::domain::{
|
||||
AvailabilityStatus, ContentGroupBuilder, InfoHash, TorrentFile, TorrentRecord,
|
||||
};
|
||||
use crate::search::SearchMode;
|
||||
use crate::storage::{RocksTorrentRepository, TorrentRepository};
|
||||
|
||||
use super::*;
|
||||
@@ -364,7 +365,7 @@ mod tests {
|
||||
let name = engine
|
||||
.search_with(SearchOptions {
|
||||
query: r"ubuntu\s+linux\s+24\.0[0-9]".into(),
|
||||
regex: true,
|
||||
mode: SearchMode::Regex,
|
||||
limit: 10,
|
||||
..SearchOptions::default()
|
||||
})
|
||||
@@ -373,7 +374,7 @@ mod tests {
|
||||
let path = engine
|
||||
.search_with(SearchOptions {
|
||||
query: r"ubuntu\.(iso|img)$".into(),
|
||||
regex: true,
|
||||
mode: SearchMode::Regex,
|
||||
limit: 10,
|
||||
..SearchOptions::default()
|
||||
})
|
||||
@@ -381,6 +382,32 @@ mod tests {
|
||||
assert_eq!(path.total, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_matches_names_and_file_extensions() {
|
||||
let directory = TempDir::new().unwrap();
|
||||
let engine = SearchEngine::open(directory.path()).unwrap();
|
||||
index_records(&engine, &[record()]);
|
||||
|
||||
for pattern in ["*.iso", "Ubuntu*", "ubuntu.?so"] {
|
||||
let page = engine
|
||||
.search_with(SearchOptions {
|
||||
query: pattern.into(),
|
||||
limit: 10,
|
||||
..SearchOptions::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(page.total, 1, "pattern {pattern}");
|
||||
}
|
||||
let missing = engine
|
||||
.search_with(SearchOptions {
|
||||
query: "*.img".into(),
|
||||
limit: 10,
|
||||
..SearchOptions::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(missing.total, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_regex_is_rejected() {
|
||||
let directory = TempDir::new().unwrap();
|
||||
@@ -388,7 +415,7 @@ mod tests {
|
||||
index_records(&engine, &[record()]);
|
||||
let result = engine.search_with(SearchOptions {
|
||||
query: "[".into(),
|
||||
regex: true,
|
||||
mode: SearchMode::Regex,
|
||||
limit: 10,
|
||||
..SearchOptions::default()
|
||||
});
|
||||
|
||||
@@ -7,7 +7,9 @@ mod query;
|
||||
mod schema;
|
||||
|
||||
pub use indexer::SearchEngine;
|
||||
pub use query::{AvailabilitySummary, SearchHit, SearchOptions, SearchPage, SearchSort};
|
||||
pub use query::{
|
||||
AvailabilitySummary, SearchHit, SearchMode, SearchOptions, SearchPage, SearchSort,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SearchError {
|
||||
|
||||
@@ -17,10 +17,18 @@ pub enum SearchSort {
|
||||
Discoveries,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SearchMode {
|
||||
#[default]
|
||||
Text,
|
||||
Regex,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SearchOptions {
|
||||
pub query: String,
|
||||
pub regex: bool,
|
||||
pub mode: SearchMode,
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
pub min_size: Option<u64>,
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ Axum 根据配置中的 `http.web_dir` 提供静态资源和单页回退 不需
|
||||
## 已实现功能
|
||||
|
||||
- 关键词 文件名片段和精确 infohash 搜索
|
||||
- 普通关键词和名称 文件路径正则搜索
|
||||
- 自动识别通配符的普通关键词搜索和名称 文件路径正则搜索
|
||||
- 相关度 时间 热度 大小和发现次数排序
|
||||
- 有上限的结果分页和 URL 查询恢复
|
||||
- 基于 reka-ui 的搜索结果数字分页和浏览器持久化每页数量选择
|
||||
|
||||
+8
-5
@@ -43,6 +43,10 @@ let statsRequestActive = false
|
||||
|
||||
const pageNumber = computed(() => Math.floor((page.value?.offset ?? 0) / limit.value) + 1)
|
||||
const paginationTotal = computed(() => Math.min(page.value?.total ?? 0, 10_000 + limit.value))
|
||||
const searchPlaceholder = computed(() => {
|
||||
if (regexMode.value) return '输入正则表达式'
|
||||
return '输入名称、文件名、*.iso 或 40 位 infohash'
|
||||
})
|
||||
const resultPageSizeOptions = [
|
||||
{ value: '10', label: '10 条/页' },
|
||||
{ value: '20', label: '20 条/页' },
|
||||
@@ -58,12 +62,11 @@ const sortOptions: ReadonlyArray<{ value: SearchSort; label: string }> = [
|
||||
{ value: 'size_desc', label: '大小降序' },
|
||||
{ value: 'size_asc', label: '大小升序' },
|
||||
]
|
||||
|
||||
function updateBrowserUrl() {
|
||||
const params = new URLSearchParams()
|
||||
if (submittedQuery.value) params.set('q', submittedQuery.value)
|
||||
if (sort.value !== 'latest') params.set('sort', sort.value)
|
||||
if (regexMode.value) params.set('regex', 'true')
|
||||
if (regexMode.value) params.set('mode', 'regex')
|
||||
if (offset.value) params.set('offset', String(offset.value))
|
||||
history.replaceState(null, '', params.size ? `?${params}` : location.pathname)
|
||||
}
|
||||
@@ -185,7 +188,7 @@ onMounted(() => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
query.value = params.get('q') ?? ''
|
||||
submittedQuery.value = query.value
|
||||
regexMode.value = params.get('regex') === 'true'
|
||||
regexMode.value = params.get('mode') === 'regex'
|
||||
const initialSort = params.get('sort') as SearchSort | null
|
||||
if (initialSort && ['relevance', 'latest', 'oldest', 'heat', 'size_desc', 'size_asc', 'discoveries'].includes(initialSort)) sort.value = initialSort
|
||||
else if (query.value.trim()) sort.value = 'relevance'
|
||||
@@ -225,7 +228,7 @@ onBeforeUnmount(() => {
|
||||
<div class="flex flex-wrap items-center gap-2 p-2">
|
||||
<Search class="ml-2.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<label class="sr-only" for="search-query">搜索关键词</label>
|
||||
<input id="search-query" v-model="query" class="h-9 min-w-48 flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground" :maxlength="regexMode ? 256 : 512" :placeholder="regexMode ? '输入正则表达式' : '输入名称、文件名或 40 位 infohash'" type="search" />
|
||||
<input id="search-query" v-model="query" class="h-9 min-w-48 flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground" :maxlength="regexMode ? 256 : 512" :placeholder="searchPlaceholder" type="search" />
|
||||
<Button size="sm" type="button" :variant="regexMode ? 'secondary' : 'ghost'" :aria-pressed="regexMode" title="正则搜索" @click="toggleRegex"><span class="font-mono">.*</span><span class="hidden sm:inline">正则</span></Button>
|
||||
<AppSelect :model-value="sort" :options="sortOptions" label="结果排序" @update:model-value="changeSort" />
|
||||
<Button type="submit">搜索</Button>
|
||||
@@ -235,7 +238,7 @@ onBeforeUnmount(() => {
|
||||
<div v-if="loading" class="flex min-h-72 items-center justify-center text-sm text-muted-foreground"><LoaderCircle class="mr-2 size-5 animate-spin" />正在搜索</div>
|
||||
<div v-else-if="error" class="mt-4 flex min-h-72 flex-col items-center justify-center gap-4 rounded-xl border border-dashed text-center"><Server class="size-8 text-muted-foreground" /><div><p class="font-medium">无法完成搜索</p><p class="mt-1 text-sm text-muted-foreground">{{ error }}</p></div><Button variant="outline" @click="loadSearch">重试</Button></div>
|
||||
<div v-else-if="page?.hits.length" class="mt-4 space-y-3"><SearchResultCard v-for="hit in page.hits" :key="hit.content_key" :hit="hit" @select="openDetail" /></div>
|
||||
<div v-else class="mt-4 flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed text-center"><Search class="mb-3 size-8 text-muted-foreground" /><p class="font-medium">没有找到匹配内容</p><p class="mt-1 text-sm text-muted-foreground">尝试其他关键词或正则表达式</p></div>
|
||||
<div v-else class="mt-4 flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed text-center"><Search class="mb-3 size-8 text-muted-foreground" /><p class="font-medium">没有找到匹配内容</p><p class="mt-1 text-sm text-muted-foreground">尝试其他关键词、通配符或正则表达式</p></div>
|
||||
|
||||
<div v-if="page && page.total > 0" class="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||
<AppSelect :model-value="String(limit)" :options="resultPageSizeOptions" label="每页搜索结果数量" @update:model-value="changeResultPageSize" />
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ export function search(input: SearchInput, signal?: AbortSignal): Promise<Search
|
||||
limit: String(input.limit),
|
||||
sort: input.sort,
|
||||
})
|
||||
if (input.regex) params.set('regex', 'true')
|
||||
if (input.regex) params.set('mode', 'regex')
|
||||
return request<SearchPage>(`/search?${params}`, signal)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user