按文件名搜索很有用,但它只能找到你还记得名字的文件。真实场景常常反过来:「我知道某份合同里提到过某个条款,但不记得文件叫什么了。」这时候需要的是搜内容——在一堆文件里 grep。
Nebula 现在支持在云端文件夹里做内容搜索:文本类文件与 PDF 都能搜,命中后给一段带上下文的片段。
复用已有能力,不重复造轮子
内容搜索听起来是个新功能,但它需要的两块能力 Nebula 早就有了:
- 读云端字节:
provider.read已经在下载、预览、同步里用了无数次; - 从 PDF 里抽文本:做 PDF 编辑器时写的
nebula_pdf::extract_text正好拿来用。
于是内容搜索的核心就很短:走文件夹收集候选文件,逐个读字节,按类型抽成文本,再匹配关键字。
let text = if ext_of(&entry.name) == "pdf" {
// PDF:复用编辑器那套文本提取
nebula_pdf::extract_text(&bytes)?.join("\n")
} else {
// 文本类:UTF-8 有损解码(遇到非法字节用 � 代替,不报错)
String::from_utf8_lossy(&bytes).into_owned()
};
if let Some(snippet) = make_snippet(&text, &needle) {
hits.push(ContentHit { entry, snippet });
}
文本文件用 from_utf8_lossy 而不是严格解码,是因为日志、CSV 这类文件里偶尔有非法字节很正常,不该让整份文件搜不了——用 � 替换掉继续搜即可。
哪些文件「值得搜」由扩展名白名单决定:常见的 .txt / .md / .json / .log / 各种源码,加上 .pdf。图片、视频、压缩包这些没有可搜文本的,直接跳过——既省下载,也避免把二进制乱码当文本匹配。
必须自带成本上限
这个功能和别的搜索有个本质区别:它要下载文件。按文件名搜只需列目录(便宜),按内容搜却要把每个候选文件的字节拉下来解析(费流量、费请求数、费时间)。所以它必须自带明确的上限,否则对着一个上万文件的桶点一下就是一场灾难:
- 单文件大小上限(16 MB):超过的直接跳过,不下载巨物;
- 扫描文件数上限(60):最多解析这么多候选,到顶就停并标记「已截断」;
- 命中数上限:够用就停。
而且它是显式切换的——搜索结果区有个「按名称 / 按内容」开关,默认按名称。内容搜索是你主动选择付出下载代价去做的事,不会在你只想按名字找东西时偷偷下载一堆文件。
这些上限让功能「够用且安全」:在一个文档目录里找一句话完全胜任,又不会因为手滑点在一个海量桶上而失控。
片段生成:一个纯函数
搜到了还得让你一眼看出为什么命中,所以每个结果带一段上下文片段——匹配词前后各约 60 个字符,折叠掉多余空白,首尾按需补省略号。
这段逻辑写成一个不碰 IO 的纯函数,这样能脱离网络单测,把「中文按字符而非字节切窗口」「大小写不敏感」「长文本首尾加 …」这些边界都锁死:
pub fn make_snippet(text: &str, needle_lower: &str) -> Option<String> {
let pos = text.to_lowercase().find(needle_lower)?; // 大小写不敏感
let chars: Vec<char> = text.chars().collect();
let char_idx = text[..pos].chars().count(); // 字节位置 → 字符索引
let start = char_idx.saturating_sub(60);
let end = (char_idx + needle_lower.chars().count() + 60).min(chars.len());
// 按字符边界取窗口,折叠空白,首尾按需加 …
...
}
有个容易忽略的坑:str::find 返回的是字节位置,而中文一个字符占多个字节。直接拿字节位置去 text[start..end] 切片,一旦切在多字节字符中间就会 panic。所以先把字节位置换算成字符索引,再在 Vec<char> 上按字符取窗口——中文文档也不会崩。
小结
- 复用:读字节 + PDF 文本提取都是现成的,内容搜索只是把它们串起来;
- 成本自觉:自带大小 / 数量上限,且显式切换,绝不偷偷下载;
- 看得懂:带上下文片段,纯函数生成、按字符安全切窗、大小写不敏感。
按文件名搜是「我知道它叫什么」,按内容搜是「我知道它里面有什么」——两个都有,才算真的能在云盘里找东西。