diff --git a/autotests/dfm-search-tests/tst_content_retriever.cpp b/autotests/dfm-search-tests/tst_content_retriever.cpp index 4aea2fa0..638c55f2 100644 --- a/autotests/dfm-search-tests/tst_content_retriever.cpp +++ b/autotests/dfm-search-tests/tst_content_retriever.cpp @@ -13,6 +13,8 @@ #include #include +#include "utils/contenthighlighter.h" + #include #include #include @@ -91,6 +93,11 @@ private Q_SLOTS: void fetchContent_semanticRoutingFailsWhenNoDConfig(); void fetchHighlight_semanticRoutingFailsWhenNoDConfig(); void concurrentFetch_sharedRetriever(); + void fetchPreview_noKeyword(); + void fetchPreview_withKeyword(); + void fetchPreview_keywordNotFound(); + void fetchPreview_offsetBeyondContent(); + void previewSnippet_basic(); }; void tst_ContentRetriever::fetchContent_single() @@ -225,6 +232,129 @@ void tst_ContentRetriever::concurrentFetch_sharedRetriever() QVERIFY(!failed.load()); } +void tst_ContentRetriever::fetchPreview_noKeyword() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString contentIndexDir = tempDir.path() + "/content-index"; + createIndex(contentIndexDir, SearchType::Content); + + ContentRetriever retriever; + retriever.setIndexDirectory(SearchType::Content, contentIndexDir); + + PreviewOptions options; + options.setKeyword(QString()); + options.setOffset(6); // skip "hello " + options.setMaxLength(5); // expect "world" + + const PreviewResult result = retriever.fetchPreview("/tmp/doc-a.txt", + SearchType::Content, + options); + QCOMPARE(result.content(), QString("world")); + QCOMPARE(result.keywordOffset(), -1); +} + +void tst_ContentRetriever::fetchPreview_withKeyword() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString contentIndexDir = tempDir.path() + "/content-index"; + createIndex(contentIndexDir, SearchType::Content); + + ContentRetriever retriever; + retriever.setIndexDirectory(SearchType::Content, contentIndexDir); + + // Content of doc-a.txt is "hello world from content index" + // "world" starts at position 6 + PreviewOptions options; + options.setKeyword("world"); + options.setOffset(0); + options.setMaxLength(10); + + const PreviewResult result = retriever.fetchPreview("/tmp/doc-a.txt", + SearchType::Content, + options); + QCOMPARE(result.content(), QString("world from")); + QCOMPARE(result.keywordOffset(), 6); +} + +void tst_ContentRetriever::fetchPreview_keywordNotFound() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString contentIndexDir = tempDir.path() + "/content-index"; + createIndex(contentIndexDir, SearchType::Content); + + ContentRetriever retriever; + retriever.setIndexDirectory(SearchType::Content, contentIndexDir); + + // Content of doc-a.txt is "hello world from content index" (30 chars) + // offset=300 is beyond the content, so keyword won't be found + PreviewOptions options; + options.setKeyword("world"); + options.setOffset(300); + options.setMaxLength(10); + + const PreviewResult result = retriever.fetchPreview("/tmp/doc-a.txt", + SearchType::Content, + options); + QVERIFY(result.content().isEmpty()); + QCOMPARE(result.keywordOffset(), -1); +} + +void tst_ContentRetriever::fetchPreview_offsetBeyondContent() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString contentIndexDir = tempDir.path() + "/content-index"; + createIndex(contentIndexDir, SearchType::Content); + + ContentRetriever retriever; + retriever.setIndexDirectory(SearchType::Content, contentIndexDir); + + // No keyword, offset beyond content length → empty content + PreviewOptions options; + options.setKeyword(QString()); + options.setOffset(1000); + options.setMaxLength(50); + + const PreviewResult result = retriever.fetchPreview("/tmp/doc-a.txt", + SearchType::Content, + options); + QVERIFY(result.content().isEmpty()); + QCOMPARE(result.keywordOffset(), -1); +} + +void tst_ContentRetriever::previewSnippet_basic() +{ + using DFMSEARCH::ContentHighlighter::previewSnippet; + + const QString content = QStringLiteral("hello world from content index"); + + // No keyword: simple offset + maxLength slice + int kwOff = 42; + QCOMPARE(previewSnippet(content, 6, 5, QString(), &kwOff), QString("world")); + QCOMPARE(kwOff, -1); + + // With keyword: search from offset, return from match position + QCOMPARE(previewSnippet(content, 0, 10, "world", &kwOff), QString("world from")); + QCOMPARE(kwOff, 6); + + // Keyword not found + QVERIFY(previewSnippet(content, 0, 10, "nonexistent", &kwOff).isEmpty()); + QCOMPARE(kwOff, -1); + + // Offset beyond content + QVERIFY(previewSnippet(content, 1000, 10, QString(), &kwOff).isEmpty()); + + // Negative offset → empty + QVERIFY(previewSnippet(content, -1, 10, QString(), &kwOff).isEmpty()); +} + QObject *create_tst_ContentRetriever() { return new tst_ContentRetriever(); diff --git a/docs/dfm-searcher-guide.md b/docs/dfm-searcher-guide.md new file mode 100644 index 00000000..196fe21a --- /dev/null +++ b/docs/dfm-searcher-guide.md @@ -0,0 +1,922 @@ +# dfm-searcher 使用指南 + +> 从零开始,学会用 `dfm-searcher` 搜索文件和预览文件内容。 + +--- + +## 它是什么 + +`dfm-searcher` 是深度文件管理器的搜索命令行工具,基于 Lucene++ 索引引擎,支持: + +- **按文件名搜索** — 用关键词、通配符、拼音找文件 +- **按文件内容搜索** — 在文档内容中搜索关键词 +- **按 OCR 文字搜索** — 在图片中识别的文字里搜索 +- **语义搜索** — 用自然语言描述需求,自动解析意图 +- **内容预览** — 按需读取已索引文件的内容片段 + +--- + +## 两种模式 + +`dfm-searcher` 有两种工作模式: + +| 模式 | 命令格式 | 用途 | +|------|---------|------| +| **搜索模式** | `dfm-searcher [选项] ` | 搜索目录下匹配的文件 | +| **预览模式** | `dfm-searcher preview [选项] [] [path...]` | 直接读取指定文件的内容片段 | + +--- + +# 第一部分:搜索 + +## 一、文件名搜索 + +### 1. 最基本的搜索 + +```bash +dfm-searcher "报告" /home/user +``` + +在 `/home/user` 目录下搜索文件名包含"报告"的文件。 + +输出示例: + +``` +/home/user/工作报告.txt +/home/user/2026年报告.docx +/home/user/reports/年度报告.pdf +``` + +### 2. 限制结果数量 + +```bash +dfm-searcher --max-results=10 "报告" /home/user +``` + +最多返回 10 条结果。`--max-results=0` 表示不限制(默认)。 + +### 3. 区分大小写 + +```bash +dfm-searcher --case-sensitive "README" /home/user +``` + +默认搜索不区分大小写。加上 `--case-sensitive` 后 "readme" 不会匹配 "README"。 + +### 4. 包含隐藏文件 + +```bash +dfm-searcher --include-hidden "config" /home/user +``` + +默认不搜索隐藏文件(以 `.` 开头的文件或目录)。加上此选项后包含隐藏文件。 + +### 5. 实时搜索 vs 索引搜索 + +```bash +# 索引搜索(默认):快,但依赖预建索引 +dfm-searcher "报告" /home/user + +# 实时搜索:慢,但不需要索引,直接遍历文件系统 +dfm-searcher --method=realtime "报告" /home/user +``` + +| 方法 | 速度 | 需要索引 | 适用场景 | +|------|------|:---:|------| +| `--method=indexed`(默认) | 快 | 是 | 日常使用 | +| `--method=realtime` | 慢 | 否 | 索引未覆盖的路径、最新文件 | + +--- + +## 二、内容搜索 + +### 6. 搜索文件内容 + +```bash +dfm-searcher --type=content "会议纪要" /home/user/Documents +``` + +在 `/home/user/Documents` 下搜索文件**内容**包含"会议纪要"的文件。 + +### 7. 搜索内容并显示预览 + +```bash +dfm-searcher --type=content -v "会议纪要" /home/user/Documents +``` + +加 `-v`(verbose)后,每条结果会附带内容预览片段(高亮显示关键词)。 + +### 8. 控制预览长度 + +```bash +dfm-searcher --type=content --max-preview=500 -v "会议" /home/user/Documents +``` + +预览片段最多 500 个字符(默认 200)。 + +--- + +## 三、OCR 搜索 + +### 9. 搜索图片中的文字 + +```bash +dfm-searcher --type=ocr "登录界面" /home/user/Pictures +``` + +在图片中搜索 OCR 识别出的文字。支持 PNG、JPG 等格式。 + +### 10. OCR 搜索 + 预览 + +```bash +dfm-searcher --type=ocr -v "验证码" /home/user/Pictures +``` + +输出会包含匹配的 OCR 文字片段。 + +--- + +## 四、通配符搜索 + +### 11. 用 * 和 ? 搜索 + +```bash +# 匹配所有以 "report" 开头的文件名 +dfm-searcher --wildcard "report*" /home/user + +# 匹配 "report" + 任意一个字符 + ".txt" +dfm-searcher --wildcard "report?.txt" /home/user + +# 匹配所有 .pdf 文件名 +dfm-searcher --wildcard "*.pdf" /home/user +``` + +| 通配符 | 含义 | 示例 | +|--------|------|------| +| `*` | 任意多个字符 | `report*` 匹配 report2026.txt、reports.pdf | +| `?` | 任意一个字符 | `report?` 匹配 reports、reportA,不匹配 reportAB | + +--- + +## 五、布尔搜索(多关键词) + +### 12. AND 搜索(所有关键词都要匹配) + +```bash +# 用逗号分隔:同时包含 "报告" 和 "2026" +dfm-searcher --query=boolean "报告,2026" /home/user + +# 用 & 分隔(同上) +dfm-searcher --query=boolean "报告&2026" /home/user +``` + +### 13. OR 搜索(任一关键词匹配即可) + +```bash +# 用 | 分隔:包含 "报告" 或 "总结" +dfm-searcher --query=boolean "报告|总结" /home/user +``` + +| 分隔符 | 逻辑 | 示例 | +|--------|------|------| +| `\|` | OR(任一匹配) | `"报告\|总结"` → 匹配报告或总结 | +| `&` | AND(全部匹配) | `"报告&2026"` → 同时包含报告和2026 | +| `,` | AND(全部匹配) | `"报告,2026"` → 同上,向后兼容 | + +--- + +## 六、拼音搜索 + +### 14. 全拼搜索 + +```bash +dfm-searcher --pinyin "baogao" /home/user +``` + +搜索文件名中拼音匹配 "baogao" 的文件,如"报告.txt"。 + +### 15. 拼音首字母搜索 + +```bash +dfm-searcher --pinyin-acronym "bg" /home/user +``` + +"bg" 匹配 "baogao"(报告)、"binggan"(饼干)等拼音首字母为 b+g 的文件名。 + +### 16. 拼音 + 布尔组合 + +```bash +dfm-searcher --pinyin --query=boolean "wendang,xinjian" /home/user +``` + +搜索文件名拼音同时匹配 "wendang"(文档)和 "xinjian"(新建)的文件。 + +--- + +## 七、文件类型过滤 + +### 17. 按类型过滤 + +```bash +dfm-searcher --file-types=doc,pic "报告" /home/user +``` + +只返回文档类型和图片类型的搜索结果。 + +支持的类型: + +| 类型 | 说明 | +|------|------| +| `app` | 应用程序文件 | +| `archive` | 压缩文件 | +| `audio` | 音频文件 | +| `doc` | 文档文件 | +| `pic` | 图片文件 | +| `video` | 视频文件 | +| `dir` | 目录 | +| `other` | 其他 | + +### 18. 按扩展名过滤 + +```bash +dfm-searcher --file-extensions=txt,pdf "报告" /home/user +``` + +只返回 .txt 和 .pdf 文件。 + +### 19. 类型 + 扩展名组合 + +```bash +dfm-searcher --file-types=doc --file-extensions=docx,odt "report" /home/user +``` + +--- + +## 八、时间范围过滤 + +### 20. 最近 N 天 + +```bash +# 最近 3 天内修改的文件 +dfm-searcher --time-last=3d "报告" /home/user + +# 最近 2 小时 +dfm-searcher --time-last=2h "报告" /home/user +``` + +支持的时间单位: + +| 单位 | 含义 | 示例 | +|------|------|------| +| `m` | 分钟 | `--time-last=30m`(最近 30 分钟) | +| `h` | 小时 | `--time-last=2h`(最近 2 小时) | +| `d` | 天 | `--time-last=7d`(最近 7 天) | +| `w` | 周 | `--time-last=2w`(最近 2 周) | +| `M` | 月 | `--time-last=3M`(最近 3 个月) | +| `y` | 年 | `--time-last=1y`(最近 1 年) | + +### 21. 预设时间范围 + +```bash +# 今天的文件 +dfm-searcher --time-today "报告" /home/user + +# 昨天的文件 +dfm-searcher --time-yesterday "报告" /home/user + +# 本周的文件 +dfm-searcher --time-this-week "报告" /home/user + +# 上个月的文件 +dfm-searcher --time-last-month "报告" /home/user + +# 今年的文件 +dfm-searcher --time-this-year "报告" /home/user +``` + +所有预设选项: + +`--time-today` / `--time-yesterday` / `--time-this-week` / `--time-last-week` / `--time-this-month` / `--time-last-month` / `--time-this-year` / `--time-last-year` + +### 22. 自定义时间范围 + +```bash +# 按日期范围 +dfm-searcher --time-range="2025-01-01,2025-12-31" "报告" /home/user + +# 按精确时间范围 +dfm-searcher --time-range="2025-06-01 09:00,2025-06-30 18:00" "报告" /home/user +``` + +### 23. 按创建时间 vs 修改时间 + +```bash +# 默认按修改时间(modify) +dfm-searcher --time-last=7d "报告" /home/user + +# 按创建时间(birth) +dfm-searcher --time-field=birth --time-last=7d "报告" /home/user +``` + +### 24. 时间 + 实时搜索组合 + +```bash +dfm-searcher --method=realtime --time-last=7d "报告" /home/user +``` + +--- + +## 九、文件大小过滤 + +### 25. 按大小范围过滤 + +```bash +# 1MB 到 100MB 之间的文件 +dfm-searcher --size-min=1M --size-max=100M "视频" /home/user +``` + +支持的大小单位: + +| 单位 | 含义 | 示例 | +|------|------|------| +| (无) | 字节 | `--size-min=512`(至少 512 字节) | +| `K` | KB | `--size-min=1K`(至少 1KB) | +| `M` | MB | `--size-min=10M`(至少 10MB) | +| `G` | GB | `--size-max=1G`(最多 1GB) | +| `T` | TB | `--size-max=1T`(最多 1TB) | + +### 26. 只限最小或最大 + +```bash +# 至少 10MB +dfm-searcher --size-min=10M "视频" /home/user + +# 最多 1KB +dfm-searcher --size-max=1K "日志" /home/user +``` + +--- + +## 十、语义搜索 + +### 27. 用自然语言搜索 + +```bash +dfm-searcher -s "最近3天的图片" /home/user +``` + +语义搜索会自动解析你的自然语言描述,提取时间、类型、关键词等维度,转化为搜索条件。 + +### 28. 语义搜索 + JSON + +```bash +dfm-searcher -s -j "最近3天的图片" /home/user +``` + +JSON 输出会包含解析出的意图(intent)信息。 + +### 29. 语义搜索不要求路径 + +```bash +dfm-searcher -s "包含会议纪要的文档" +``` + +语义模式可以不指定搜索路径。 + +### 30. 语义搜索示例集 + +```bash +# 查找最近图片 +dfm-searcher -s "最近的图片" /home/user + +# 查找上周的文档 +dfm-searcher -s "上周的文档" /home/user/Documents + +# 查找大视频 +dfm-searcher -s "大于1G的视频" /home/user + +# 查找特定内容 +dfm-searcher -s "内容包含会议纪要的文件" /home/user + +# 语义 + 详细输出 +dfm-searcher -s -v "最近3天的图片" /home/user +``` + +--- + +## 十一、输出格式 + +### 31. JSON 输出 + +```bash +dfm-searcher -j "报告" /home/user +``` + +输出结构化 JSON,方便脚本解析: + +```json +{ + "type": "search", + "searchType": "filename", + "keyword": "报告", + "totalResults": 2, + "results": [ + { + "path": "/home/user/工作报告.txt", + "filename": "工作报告.txt" + }, + { + "path": "/home/user/2026年报告.docx", + "filename": "2026年报告.docx" + } + ] +} +``` + +### 32. 详细输出(verbose) + +```bash +dfm-searcher -v --type=content "会议" /home/user/Documents +``` + +`-v` 启用详细模式,结果中会包含文件大小、修改时间、高亮内容预览等额外信息。 + +### 33. JSON + verbose 组合 + +```bash +dfm-searcher -j -v --type=content "会议" /home/user/Documents +``` + +--- + +## 十二、组合搜索 + +### 34. 文件名 + 文件类型 + 时间 + +```bash +dfm-searcher --file-types=doc --time-last=7d "报告" /home/user +``` + +最近 7 天内的文档类型文件,文件名包含"报告"。 + +### 35. 内容搜索 + 文件名过滤 + 大小过滤 + +```bash +dfm-searcher --type=content --filename="季度" --size-min=1M "财务" /home/user +``` + +在内容包含"财务"且文件名包含"季度"、大小至少 1MB 的文件中搜索。 + +### 36. 实时搜索 + 时间 + 大小 + 类型 + +```bash +dfm-searcher --method=realtime --time-last=30d --size-min=10M --file-types=video "监控" /home/user +``` + +--- + +# 第二部分:内容预览(preview 子命令) + +`preview` 子命令用来**直接读取**指定文件的内容片段,不需要运行完整搜索。 + +## 十三、基本用法 + +### 37. 读取文件开头内容 + +```bash +dfm-searcher preview /home/user/notes.txt +``` + +默认读取前 200 个字符。 + +``` +/home/user/notes.txt + 2026年7月10日 工作计划:1.完成需求文档 2.代码评审 3.修复bug +``` + +### 38. 读取文件内容(JSON 格式) + +```bash +dfm-searcher preview /home/user/notes.txt -j +``` + +```json +{ + "type": "preview", + "searchType": "semantic", + "keyword": "", + "offset": 0, + "maxLength": 200, + "totalResults": 1, + "results": [ + { + "path": "/home/user/notes.txt", + "content": "2026年7月10日 工作计划:1.完成需求文档 2.代码评审 3.修复bug", + "keywordOffset": -1 + } + ] +} +``` + +--- + +## 十四、用 --offset 和 --max-preview 控制读取范围 + +### 39. 从第 100 个字符开始读 + +```bash +dfm-searcher preview /home/user/notes.txt --offset=100 +``` + +### 40. 只读 50 个字符 + +```bash +dfm-searcher preview /home/user/notes.txt --max-preview=50 +``` + +### 41. 从第 100 个字符开始,只读 50 个字符 + +```bash +dfm-searcher preview /home/user/notes.txt --offset=100 --max-preview=50 +``` + +### 42. 翻页式读取 + +第一次读取: + +```bash +dfm-searcher preview /home/user/notes.txt --max-preview=100 -j +``` + +第二次,offset=100: + +```bash +dfm-searcher preview /home/user/notes.txt --offset=100 --max-preview=100 -j +``` + +第三次,offset=200: + +```bash +dfm-searcher preview /home/user/notes.txt --offset=200 --max-preview=100 -j +``` + +以此类推,每次读 100 个字符。 + +### 43. offset 超过文件长度 → 返回空 + +```bash +dfm-searcher preview /home/user/notes.txt --offset=99999 -j +``` + +```json +{ + "results": [ + { + "content": "", + "keywordOffset": -1 + } + ] +} +``` + +空内容说明已读到文件末尾。 + +--- + +## 十五、用 keyword 搜索并预览 + +### 44. 搜索关键词并预览 + +```bash +dfm-searcher preview "bug" /home/user/notes.txt +``` + +``` +/home/user/notes.txt + bug修复:修复了文件管理器在删除大量文件时的崩溃问题 +``` + +内容从关键词所在位置开始,往后最多 200 个字符。 + +### 45. 搜索关键词 + JSON + +```bash +dfm-searcher preview "bug" /home/user/notes.txt -j +``` + +```json +{ + "type": "preview", + "keyword": "bug", + "offset": 0, + "maxLength": 200, + "results": [ + { + "path": "/home/user/notes.txt", + "content": "bug修复:修复了文件管理器在删除大量文件时的崩溃问题", + "keywordOffset": 156 + } + ] +} +``` + +`keywordOffset: 156` 表示 "bug" 在全文第 156 个字符处。 + +### 46. 从指定位置开始搜索关键词 + +如果 "bug" 在文件里出现多次,用 `--offset` 跳过前面的出现: + +```bash +# 从第 200 个字符开始搜索 "bug" +dfm-searcher preview "bug" /home/user/notes.txt --offset=200 +``` + +### 47. offset 后面没有关键词 → 返回空 + +```bash +dfm-searcher preview "bug" /home/user/notes.txt --offset=500 -j +``` + +```json +{ + "results": [ + { + "content": "", + "keywordOffset": -1 + } + ] +} +``` + +### 48. 搭配搜索结果使用 + +搜索返回 `keywordOffset`,可以用它作为 preview 的 `--offset`: + +```bash +# 假设搜索返回 keywordOffset=320 +dfm-searcher preview "关键词" /home/user/doc.txt --offset=320 --max-preview=200 -j +``` + +--- + +## 十六、批量预览多个文件 + +### 49. 同时预览多个文件 + +```bash +dfm-searcher preview "报告" doc1.txt doc2.txt doc3.txt +``` + +``` +/home/user/doc1.txt + 报告:2026年Q1财务总结… +/home/user/doc2.txt + 报告:2026年Q2项目进展… +/home/user/doc3.txt + (no content) +``` + +### 50. 批量预览 + JSON + +```bash +dfm-searcher preview "报告" doc1.txt doc2.txt doc3.txt -j +``` + +```json +{ + "type": "preview", + "keyword": "报告", + "offset": 0, + "maxLength": 200, + "totalResults": 3, + "results": [ + { + "path": "/home/user/doc1.txt", + "content": "报告:2026年Q1财务总结…", + "keywordOffset": 0 + }, + { + "path": "/home/user/doc2.txt", + "content": "报告:2026年Q2项目进展…", + "keywordOffset": 5 + }, + { + "path": "/home/user/doc3.txt", + "content": "", + "keywordOffset": -1 + } + ] +} +``` + +--- + +## 十七、搜索类型 + +### 51. 自动检测(默认) + +不给 `--type`,程序根据文件扩展名自动判断: +- `.txt` `.doc` `.md` 等 → 用内容索引(content) +- `.png` `.jpg` 等 → 用 OCR 索引(ocr) + +### 52. 强制指定搜索类型 + +```bash +# 强制用 OCR 索引 +dfm-searcher preview --type=ocr "截图" photo.png + +# 强制用内容索引 +dfm-searcher preview --type=content "会议" doc.txt +``` + +### 53. 预览 OCR 图片中的文字 + +```bash +dfm-searcher preview "登录" screenshot.png -j +``` + +```json +{ + "type": "preview", + "searchType": "ocr", + "keyword": "登录", + "results": [ + { + "path": "screenshot.png", + "content": "登录界面请输入用户名和密码", + "keywordOffset": 0 + } + ] +} +``` + +--- + +## 十八、无关键词,纯读取内容 + +### 54. 读取文件前 200 个字符 + +```bash +dfm-searcher preview /home/user/doc.txt +``` + +### 55. 从第 500 个字符读取 100 个字符 + +```bash +dfm-searcher preview /home/user/doc.txt --offset=500 --max-preview=100 +``` + +### 56. 无关键词 + JSON + +```bash +dfm-searcher preview /home/user/doc.txt --offset=500 --max-preview=100 -j +``` + +```json +{ + "keyword": "", + "offset": 500, + "maxLength": 100, + "results": [ + { + "content": "这里是第500到600个字符之间的原始内容", + "keywordOffset": -1 + } + ] +} +``` + +`keywordOffset: -1` 因为没有给关键词。 + +--- + +# 第三部分:参考 + +## 选项速查表 + +### 搜索模式选项 + +| 选项 | 默认值 | 说明 | +|------|--------|------| +| `--type=` | `filename` | 搜索类型 | +| `--method=` | `indexed` | 搜索方法 | +| `--query=` | `simple` | 查询类型 | +| `--wildcard` | 关 | 启用通配符搜索 | +| `--case-sensitive` | 关 | 区分大小写 | +| `--include-hidden` | 关 | 包含隐藏文件 | +| `--pinyin` | 关 | 启用拼音搜索 | +| `--pinyin-acronym` | 关 | 启用拼音首字母搜索 | +| `--file-types=` | 无 | 按文件类型过滤 | +| `--file-extensions=` | 无 | 按扩展名过滤 | +| `--max-results=` | `0`(不限) | 最大结果数 | +| `--max-preview=` | `200` | 内容预览长度 | +| `--filename=` | 无 | 在内容/OCR 搜索中按文件名过滤 | +| `--semantic`, `-s` | 关 | 启用语义搜索 | +| `--time-field=` | `modify` | 时间字段 | +| `--time-last=` | 无 | 最近 N 时间段 | +| `--time-today` 等预设 | 无 | 预设时间范围 | +| `--time-range=,` | 无 | 自定义时间范围 | +| `--size-min=` | 无 | 最小文件大小 | +| `--size-max=` | 无 | 最大文件大小 | +| `--json`, `-j` | 关 | JSON 输出 | +| `--verbose`, `-v` | 关 | 详细输出 | +| `--version` | — | 显示版本 | +| `--help` | — | 显示帮助 | + +### Preview 模式选项 + +| 选项 | 默认值 | 说明 | +|------|--------|------| +| `--offset=` | `0` | 从文档第几个字符开始阅读/搜索 | +| `--max-preview=` | `200` | 本次最多读取的字符数 | +| `--type=` | 自动 | 强制指定搜索类型 | +| `-j` / `--json` | 关 | JSON 输出 | + +--- + +## JSON 输出字段说明 + +### 搜索模式 JSON + +| 字段 | 位置 | 说明 | +|------|------|------| +| `type` | 顶层 | `"search"` | +| `searchType` | 顶层 | `"filename"` / `"content"` / `"ocr"` / `"semantic"` | +| `keyword` | 顶层 | 搜索关键词 | +| `totalResults` | 顶层 | 结果总数 | +| `path` | result 项 | 文件路径 | +| `filename` | result 项 | 文件名(verbose 模式) | +| `highlightedContent` | result 项 | 高亮内容预览(content/ocr + verbose) | + +### Preview 模式 JSON + +| 字段 | 位置 | 说明 | +|------|------|------| +| `type` | 顶层 | `"preview"` | +| `searchType` | 顶层 | `"content"` / `"ocr"` / `"semantic"` | +| `keyword` | 顶层 | 搜索关键词,空字符串表示无关键词 | +| `offset` | 顶层 | 本次使用的偏移量 | +| `maxLength` | 顶层 | 本次最大读取长度 | +| `totalResults` | 顶层 | 结果总数(等于文件数) | +| `path` | result 项 | 文件路径 | +| `content` | result 项 | 截取到的内容片段 | +| `keywordOffset` | result 项 | keyword 在全文中的位置(-1 = 无 keyword 或未匹配) | + +--- + +## 字符计数说明 + +`--offset` 以**字符**(QChar)为单位,不是字节。 + +一个汉字算一个字符,一个换行符 `\n` 也算一个字符。 + +例如: + +``` +你好\n世界 +``` + +| 位置 | 字符 | +|------|------| +| 0 | 你 | +| 1 | 好 | +| 2 | \n | +| 3 | 世 | +| 4 | 界 | + +要从"世"开始读,`--offset=3`。 + +--- + +## 常见问题 + +### Q: preview 报 "(no content)" 是什么意思? + +可能原因: +1. 文件未被索引(需要先建索引) +2. offset 超出文件长度 +3. 给了 keyword 但文件中没有这个词(或在 offset 之后没有) + +### Q: preview 和普通搜索有什么区别? + +普通搜索搜索整个目录返回匹配文件列表。preview 不搜索,而是直接读取指定文件的内容片段。 + +### Q: 可以预览任意文件吗? + +不能。preview 只能读取已被索引的文件。文件需要先通过文件管理器的内容索引功能建立索引。 + +### Q: --offset 是字节还是字符? + +是字符(QChar)。一个中文字算一个字符,一个换行符也算一个字符。不是字节。 + +### Q: 索引搜索找不到文件怎么办? + +可以改用实时搜索:`--method=realtime`,它会直接遍历文件系统,不需要索引。 + +### Q: --version 和 -v 冲突吗? + +不冲突。`--version` 是长选项显示版本,`-v` 是 `--verbose` 的短选项。两者不冲突。 + +### Q: 选项可以放在任何位置吗? + +可以。`dfm-searcher --type=content "hello" /home/user` 和 `dfm-searcher "hello" /home/user --type=content` 效果一样。 diff --git a/include/dfm-search/dfm-search/contentretriever.h b/include/dfm-search/dfm-search/contentretriever.h index 4372d2c1..2143d05e 100644 --- a/include/dfm-search/dfm-search/contentretriever.h +++ b/include/dfm-search/dfm-search/contentretriever.h @@ -15,6 +15,8 @@ DFM_SEARCH_BEGIN_NS class HighlightOptionsPrivate; +class PreviewOptionsPrivate; +class PreviewResultPrivate; /** * @brief Lightweight options for highlight extraction (Pimpl-based, ABI-stable) @@ -47,6 +49,63 @@ class HighlightOptions QSharedDataPointer d; }; +/** + * @brief Options for precise preview extraction (Pimpl-based, ABI-stable) + * + * Controls offset-based content reading: either a raw slice from offset + * (no keyword) or a keyword search starting from offset. + * + * Supports implicit sharing (COW) via QSharedDataPointer. + */ +class PreviewOptions +{ +public: + PreviewOptions(); + ~PreviewOptions(); + PreviewOptions(const PreviewOptions &other); + PreviewOptions &operator=(const PreviewOptions &other); + PreviewOptions(PreviewOptions &&other) noexcept = default; + PreviewOptions &operator=(PreviewOptions &&other) noexcept = default; + + int offset() const; + void setOffset(int offset); + + int maxLength() const; + void setMaxLength(int length); + + QString keyword() const; + void setKeyword(const QString &keyword); + +private: + QSharedDataPointer d; +}; + +/** + * @brief Result of a preview extraction (Pimpl-based, ABI-stable) + * + * Contains the extracted content snippet and the position of the keyword + * in the full document text (-1 if no keyword or not matched). + * + * Supports implicit sharing (COW) via QSharedDataPointer. + */ +class PreviewResult +{ +public: + PreviewResult(); + ~PreviewResult(); + PreviewResult(const PreviewResult &other); + PreviewResult &operator=(const PreviewResult &other); + PreviewResult(PreviewResult &&other) noexcept = default; + PreviewResult &operator=(PreviewResult &&other) noexcept = default; + + QString content() const; + int keywordOffset() const; + +private: + friend class ContentRetriever; + QSharedDataPointer d; +}; + /** * @brief Retrieves highlighted content from Lucene index on demand * @@ -129,6 +188,24 @@ class ContentRetriever : public QObject QMap fetchContents(const QStringList &paths, SearchType type) const; + /** + * @brief Synchronously fetch a precise preview snippet for a single file + * + * Opens the Lucene index, locates the document by path, extracts stored + * text, and runs ContentHighlighter::previewSnippet to produce a raw + * content slice based on offset/maxLength/keyword. + * + * Unlike fetchHighlight, this does NOT simplify, add ellipsis, or + * highlight — it returns the exact content at the requested position. + * + * @param path Absolute file path + * @param type SearchType::Content or SearchType::Ocr + * @param options Preview configuration (offset, maxLength, keyword) + * @return PreviewResult with content snippet and keywordOffset + */ + PreviewResult fetchPreview(const QString &path, SearchType type, + const PreviewOptions &options = {}) const; + private: struct Private; std::unique_ptr d; diff --git a/src/dfm-search/dfm-search-client/cli_options.cpp b/src/dfm-search/dfm-search-client/cli_options.cpp index d8d34bd3..45fba883 100644 --- a/src/dfm-search/dfm-search-client/cli_options.cpp +++ b/src/dfm-search/dfm-search-client/cli_options.cpp @@ -27,6 +27,7 @@ CliOptions::CliOptions() m_fileExtensionsOption(QStringList() << "file-extensions", "Filter by file extensions, comma separated", "extensions"), m_maxResultsOption(QStringList() << "max-results", "Maximum number of results (0 for unlimited)", "number", "0"), m_maxPreviewOption(QStringList() << "max-preview", "Max content preview length", "length", "200"), + m_offsetOption(QStringList() << "offset", "Content offset: start reading from the n-th character (preview only)", "n", "0"), m_filenameOption(QStringList() << "filename", "Search by filename in content/ocr index", "keyword"), m_wildcardOption(QStringList() << "wildcard", "Enable wildcard search with * and ? patterns"), m_jsonOption(QStringList() << "json" @@ -74,6 +75,7 @@ void CliOptions::setupOptions() m_parser.addOption(m_fileExtensionsOption); m_parser.addOption(m_maxResultsOption); m_parser.addOption(m_maxPreviewOption); + m_parser.addOption(m_offsetOption); m_parser.addOption(m_filenameOption); m_parser.addOption(m_wildcardOption); m_parser.addOption(m_jsonOption); @@ -129,6 +131,7 @@ void CliOptions::printHelp() const std::cout << " --file-extensions= Filter by file extensions, comma separated" << std::endl; std::cout << " --max-results= Maximum number of results (0 for unlimited)" << std::endl; std::cout << " --max-preview= Max content preview length (for content/ocr search)" << std::endl; + std::cout << " --offset= Content offset: start reading from the n-th character (preview only, default 0)" << std::endl; std::cout << " --filename= Search by filename in content/ocr index" << std::endl; std::cout << std::endl; std::cout << "Time Range Filter Options:" << std::endl; @@ -183,10 +186,10 @@ void CliOptions::printHelp() const std::cout << " dfm-searcher --size-min=1M --size-max=100M \"video\" /home/user" << std::endl; std::cout << std::endl; std::cout << "Content Preview (on-demand):" << std::endl; - std::cout << " dfm-searcher preview [] [path2 ...] [--type=] [-j]" << std::endl; + std::cout << " dfm-searcher preview [] [path2 ...] [--type=] [--offset=] [--max-preview=] [-j]" << std::endl; std::cout << " Fetch content snippets for specific files without running a full search." << std::endl; - std::cout << " With keyword: returns highlighted snippets matching the keyword." << std::endl; - std::cout << " Without keyword: returns full stored content of each file." << std::endl; + std::cout << " With keyword: returns snippet from the keyword match position (search starts at --offset)." << std::endl; + std::cout << " Without keyword: returns exact content starting from --offset, up to --max-preview characters." << std::endl; std::cout << " Search type is auto-detected by file extension; use --type to force a specific index." << std::endl; std::cout << std::endl; std::cout << " # Fetch highlighted snippet for a single file" << std::endl; @@ -195,6 +198,12 @@ void CliOptions::printHelp() const std::cout << " # Fetch full content (no keyword)" << std::endl; std::cout << " dfm-searcher preview /home/user/doc.txt" << std::endl; std::cout << std::endl; + std::cout << " # Read content from offset 100, 50 chars max" << std::endl; + std::cout << " dfm-searcher preview /home/user/doc.txt --offset=100 --max-preview=50 -j" << std::endl; + std::cout << std::endl; + std::cout << " # Search keyword from offset 150" << std::endl; + std::cout << " dfm-searcher preview \"keyword\" /home/user/doc.txt --offset=150 -j" << std::endl; + std::cout << std::endl; std::cout << " # Batch fetch highlighted snippets with JSON output" << std::endl; std::cout << " dfm-searcher preview \"screenshot\" img1.png img2.png -j" << std::endl; std::cout << std::endl; @@ -273,6 +282,13 @@ bool CliOptions::parse(QCoreApplication &app, SearchCliConfig &config) config.maxPreviewLength = previewLength; } } + if (m_parser.isSet(m_offsetOption)) { + bool ok; + int offsetVal = m_parser.value(m_offsetOption).toInt(&ok); + if (ok && offsetVal >= 0) { + config.offset = offsetVal; + } + } return true; } diff --git a/src/dfm-search/dfm-search-client/cli_options.h b/src/dfm-search/dfm-search-client/cli_options.h index d5823ad5..39f5f1f8 100644 --- a/src/dfm-search/dfm-search-client/cli_options.h +++ b/src/dfm-search/dfm-search-client/cli_options.h @@ -47,6 +47,7 @@ struct SearchCliConfig QStringList fileExtensions; int maxResults = 0; // 0 表示不限制 int maxPreviewLength = 200; + int offset = 0; // 内容偏移值,preview 子命令使用 // 文件名搜索选项 QString filenameKeyword; @@ -107,6 +108,7 @@ class CliOptions QCommandLineOption m_fileExtensionsOption; QCommandLineOption m_maxResultsOption; QCommandLineOption m_maxPreviewOption; + QCommandLineOption m_offsetOption; QCommandLineOption m_filenameOption; QCommandLineOption m_wildcardOption; QCommandLineOption m_jsonOption; diff --git a/src/dfm-search/dfm-search-client/main.cpp b/src/dfm-search/dfm-search-client/main.cpp index f4f434ee..22069de4 100644 --- a/src/dfm-search/dfm-search-client/main.cpp +++ b/src/dfm-search/dfm-search-client/main.cpp @@ -186,8 +186,10 @@ int main(int argc, char *argv[]) // Preview subcommand: fetch content preview on demand if (config.subcommand == "preview") { DFMSEARCH::ContentRetriever retriever; - DFMSEARCH::HighlightOptions hlOptions; - hlOptions.setMaxPreviewLength(config.maxPreviewLength); + DFMSEARCH::PreviewOptions previewOptions; + previewOptions.setOffset(config.offset); + previewOptions.setMaxLength(config.maxPreviewLength); + previewOptions.setKeyword(config.keyword); // Paths are stored as comma-separated in config.searchPath #if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) @@ -196,8 +198,6 @@ int main(int argc, char *argv[]) QStringList paths = config.searchPath.split(',', QString::SkipEmptyParts); #endif - bool noKeyword = config.keyword.isEmpty(); - if (config.jsonOutput) { // JSON output QJsonObject root; @@ -205,15 +205,16 @@ int main(int argc, char *argv[]) root["searchType"] = (config.searchType == SearchType::Content) ? "content" : (config.searchType == SearchType::Ocr) ? "ocr" : "semantic"; root["keyword"] = config.keyword; + root["offset"] = config.offset; + root["maxLength"] = config.maxPreviewLength; QJsonArray results; for (const QString &path : paths) { QJsonObject item; item["path"] = path; - if (noKeyword) - item["content"] = retriever.fetchContent(path, config.searchType); - else - item["contentMatch"] = retriever.fetchHighlight(path, config.keyword, config.searchType, hlOptions); + DFMSEARCH::PreviewResult result = retriever.fetchPreview(path, config.searchType, previewOptions); + item["content"] = result.content(); + item["keywordOffset"] = result.keywordOffset(); results.append(item); } @@ -227,20 +228,11 @@ int main(int argc, char *argv[]) QTextStream out(stdout); for (const QString &path : paths) { out << path << "\n"; - if (noKeyword) { - QString content = retriever.fetchContent(path, config.searchType); - if (!content.isEmpty()) { - out << " " << content << "\n"; - } else { - out << " (no content)\n"; - } + DFMSEARCH::PreviewResult result = retriever.fetchPreview(path, config.searchType, previewOptions); + if (!result.content().isEmpty()) { + out << " " << result.content() << "\n"; } else { - QString hl = retriever.fetchHighlight(path, config.keyword, config.searchType, hlOptions); - if (!hl.isEmpty()) { - out << " " << hl << "\n"; - } else { - out << " (no match)\n"; - } + out << " (no content)\n"; } out << Qt::endl; } diff --git a/src/dfm-search/dfm-search-lib/utils/contenthighlighter.cpp b/src/dfm-search/dfm-search-lib/utils/contenthighlighter.cpp index e6f1c703..3b82ca7a 100644 --- a/src/dfm-search/dfm-search-lib/utils/contenthighlighter.cpp +++ b/src/dfm-search/dfm-search-lib/utils/contenthighlighter.cpp @@ -242,6 +242,35 @@ QString customHighlight(const QStringList &keywords, const QString &content, int return resultSnippet; } +QString previewSnippet(const QString &content, int offset, int maxLength, + const QString &keyword, int *keywordOffset) +{ + if (keywordOffset) { + *keywordOffset = -1; + } + + if (content.isEmpty() || maxLength <= 0 || offset < 0) { + return {}; + } + + if (keyword.isEmpty()) { + // 无 keyword:直接从 offset 截取 maxLength + return content.mid(offset, maxLength); + } + + // 有 keyword:从 offset 开始搜索,找到后从匹配位置截取 + int pos = content.indexOf(keyword, offset, Qt::CaseInsensitive); + if (pos == -1) { + return {}; + } + + if (keywordOffset) { + *keywordOffset = pos; + } + + return content.mid(pos, maxLength); +} + } // namespace ContentHighlighter DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/utils/contenthighlighter.h b/src/dfm-search/dfm-search-lib/utils/contenthighlighter.h index fbaffdd5..0595805f 100644 --- a/src/dfm-search/dfm-search-lib/utils/contenthighlighter.h +++ b/src/dfm-search/dfm-search-lib/utils/contenthighlighter.h @@ -40,6 +40,24 @@ namespace ContentHighlighter { */ QString customHighlight(const QStringList &keywords, const QString &content, int maxLength, bool enableHtml, int positioningMaxLength = 30); +/** + * @brief 精确预览:从 offset 位置截取内容,或从 offset 搜索 keyword 后从匹配位置截取 + * + * 无 keyword: content.mid(offset, maxLength) + * 有 keyword: content.indexOf(keyword, offset) → 从匹配位置截取 maxLength + * 不做 simplified,不加省略号,不高亮 + * + * @param content 原始文档内容 + * @param offset 内容偏移值(默认0) + * @param maxLength 最大截取长度 + * @param keyword 搜索关键词(可选) + * @param keywordOffset 输出 keyword 在全文中的匹配位置(-1 if 无 keyword 或未匹配) + * @return 截取的内容片段,找不到 keyword 或 offset 越界时返回空字符串 + */ +QString previewSnippet(const QString &content, int offset, int maxLength, + const QString &keyword = {}, + int *keywordOffset = nullptr); + } // namespace ContentHighlighter DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/utils/contentretriever.cpp b/src/dfm-search/dfm-search-lib/utils/contentretriever.cpp index 9efd3e0a..1b3c0e24 100644 --- a/src/dfm-search/dfm-search-lib/utils/contentretriever.cpp +++ b/src/dfm-search/dfm-search-lib/utils/contentretriever.cpp @@ -8,6 +8,8 @@ #include "utils/contenthighlighter.h" #include "utils/highlightoptions_p.h" +#include "utils/previewoptions_p.h" +#include "utils/previewresult_p.h" #include "utils/searchutility.h" #include @@ -165,6 +167,72 @@ void HighlightOptions::setEnableHtml(bool enable) d->enableHtml = enable; } +// ── PreviewOptions (Pimpl) ───────────────────────────────────────────── + +PreviewOptions::PreviewOptions() + : d(new PreviewOptionsPrivate) +{ +} + +PreviewOptions::~PreviewOptions() = default; + +PreviewOptions::PreviewOptions(const PreviewOptions &other) = default; + +PreviewOptions &PreviewOptions::operator=(const PreviewOptions &other) = default; + +int PreviewOptions::offset() const +{ + return d->offset; +} + +void PreviewOptions::setOffset(int offset) +{ + d->offset = offset; +} + +int PreviewOptions::maxLength() const +{ + return d->maxLength; +} + +void PreviewOptions::setMaxLength(int length) +{ + d->maxLength = length; +} + +QString PreviewOptions::keyword() const +{ + return d->keyword; +} + +void PreviewOptions::setKeyword(const QString &keyword) +{ + d->keyword = keyword; +} + +// ── PreviewResult (Pimpl) ────────────────────────────────────────────── + +PreviewResult::PreviewResult() + : d(new PreviewResultPrivate) +{ +} + +PreviewResult::~PreviewResult() = default; + +PreviewResult::PreviewResult(const PreviewResult &other) = default; + +PreviewResult &PreviewResult::operator=(const PreviewResult &other) = default; + +QString PreviewResult::content() const +{ + return d->content; +} + +int PreviewResult::keywordOffset() const +{ + return d->keywordOffset; +} + // ── ContentRetriever ─────────────────────────────────────────────────── struct ContentRetriever::Private @@ -453,4 +521,55 @@ QMap ContentRetriever::fetchContents(const QStringList &paths, return results; } +PreviewResult ContentRetriever::fetchPreview(const QString &path, SearchType type, + const PreviewOptions &options) const +{ + PreviewResult result; + if (path.isEmpty()) return result; + + // Route SearchType::Semantic to Content or Ocr based on file extension + if (type == SearchType::Semantic) { + std::optional resolved = resolveSemanticType(path); + if (!resolved) { + qWarning() << "ContentRetriever: cannot route Semantic type for" << path + << "- extension does not match doc or pic suffixes"; + return result; + } + type = *resolved; + } else if (type != SearchType::Content && type != SearchType::Ocr) { + return result; + } + + const QString indexDir = indexDirectory(type); + + QMutexLocker locker(&d->mutex); + CachedIndexContext *ctx = d->ensureIndexContext(type, indexDir); + if (!ctx || !ctx->searcher) { + return result; + } + + try { + const DocumentPtr doc = findDocumentByPath(ctx->searcher, path, type); + const QString content = storedContentFromDocument(doc, type); + if (content.isEmpty()) { + return result; + } + + int keywordOffset = -1; + const QString snippet = ContentHighlighter::previewSnippet( + content, options.offset(), options.maxLength(), + options.keyword(), &keywordOffset); + + result.d->content = snippet; + result.d->keywordOffset = keywordOffset; + } catch (const LuceneException &e) { + qWarning() << "ContentRetriever: error fetching preview for" << path + << QString::fromStdWString(e.getError()); + } catch (const std::exception &e) { + qWarning() << "ContentRetriever: std error for" << path << e.what(); + } + + return result; +} + DFM_SEARCH_END_NS diff --git a/src/dfm-search/dfm-search-lib/utils/previewoptions_p.h b/src/dfm-search/dfm-search-lib/utils/previewoptions_p.h new file mode 100644 index 00000000..9a4814b3 --- /dev/null +++ b/src/dfm-search/dfm-search-lib/utils/previewoptions_p.h @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef PREVIEWOPTIONS_P_H +#define PREVIEWOPTIONS_P_H + +#include +#include + +#include + +DFM_SEARCH_BEGIN_NS + +class PreviewOptionsPrivate : public QSharedData +{ +public: + PreviewOptionsPrivate() = default; + PreviewOptionsPrivate(const PreviewOptionsPrivate &other) = default; + + int offset = 0; ///< Content offset: start reading from the n-th character + int maxLength = 200; ///< Maximum snippet length in characters + QString keyword; ///< Search keyword (empty = raw offset read) +}; + +DFM_SEARCH_END_NS + +#endif // PREVIEWOPTIONS_P_H diff --git a/src/dfm-search/dfm-search-lib/utils/previewresult_p.h b/src/dfm-search/dfm-search-lib/utils/previewresult_p.h new file mode 100644 index 00000000..6dd21841 --- /dev/null +++ b/src/dfm-search/dfm-search-lib/utils/previewresult_p.h @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef PREVIEWRESULT_P_H +#define PREVIEWRESULT_P_H + +#include +#include + +#include + +DFM_SEARCH_BEGIN_NS + +class PreviewResultPrivate : public QSharedData +{ +public: + PreviewResultPrivate() = default; + PreviewResultPrivate(const PreviewResultPrivate &other) = default; + + QString content; ///< Extracted content snippet + int keywordOffset = -1; ///< Position of keyword in full content (-1 if none/not matched) +}; + +DFM_SEARCH_END_NS + +#endif // PREVIEWRESULT_P_H