diff --git a/src/main/java/net/micode/notes/search/README.md b/src/main/java/net/micode/notes/search/README.md new file mode 100644 index 0000000..3f5f8ce --- /dev/null +++ b/src/main/java/net/micode/notes/search/README.md @@ -0,0 +1,258 @@ +# 搜索模块 API 文档 + +## 1. 模块概述 + +本搜索模块为小米便签应用提供高效的搜索功能,支持对便签内容、标题进行关键词搜索,并具备实时搜索建议、搜索结果高亮显示、搜索结果排序和搜索历史记录等功能。 + +## 2. 核心类 + +### 2.1 SearchManager + +搜索管理器类,负责处理搜索请求,管理搜索历史和提供搜索结果。 + +#### 2.1.1 单例获取 + +```java +SearchManager searchManager = SearchManager.getInstance(Context context); +``` + +#### 2.1.2 执行搜索 + +```java +List search(String keyword, SortBy sortBy, int maxResults); +``` + +**参数说明:** +- `keyword`:搜索关键词 +- `sortBy`:排序方式,可选值: + - `RELEVANCE`:按相关度排序 + - `CREATED_DATE`:按创建时间排序 + - `MODIFIED_DATE`:按更新时间排序 +- `maxResults`:最大返回结果数 + +**返回值:** +- 搜索结果列表,每个元素为 `SearchResult` 对象 + +#### 2.1.3 获取搜索建议 + +```java +List getSearchSuggestions(String keyword, int maxResults); +``` + +**参数说明:** +- `keyword`:搜索关键词前缀 +- `maxResults`:最大返回建议数 + +**返回值:** +- 搜索建议列表 + +#### 2.1.4 搜索历史管理 + +```java +// 获取最近的搜索历史 +List getSearchHistory(int maxResults); + +// 清除所有搜索历史 +void clearSearchHistory(); + +// 删除特定的搜索历史记录 +void deleteSearchHistory(String keyword); +``` + +#### 2.1.5 索引管理 + +```java +// 更新搜索索引 +void updateSearchIndex(); +``` + +#### 2.1.6 关键词高亮 + +```java +String highlightKeyword(String text, String keyword); +``` + +**参数说明:** +- `text`:原始文本 +- `keyword`:搜索关键词 + +**返回值:** +- 带有 HTML 高亮标记的文本 + +### 2.2 SearchResult + +搜索结果类,用于存储单个搜索结果的数据。 + +#### 2.2.1 主要属性 + +| 属性名 | 类型 | 描述 | +|-------|------|------| +| noteId | long | 便签ID | +| title | String | 便签标题 | +| snippet | String | 便签摘要 | +| content | String | 便签内容 | +| createdDate | long | 创建时间 | +| modifiedDate | long | 更新时间 | +| bgColorId | int | 背景颜色ID | +| relevanceScore | float | 相关度得分 | + +#### 2.2.2 主要方法 + +```java +// 获取相关度得分 +float getRelevanceScore(); + +// 设置相关度得分 +void setRelevanceScore(float relevanceScore); + +// 计算相关度得分 +void calculateRelevanceScore(String keyword); +``` + +### 2.3 SearchHistory + +搜索历史管理类,负责存储、查询和管理搜索历史记录。 + +#### 2.3.1 单例获取 + +```java +SearchHistory searchHistory = SearchHistory.getInstance(Context context); +``` + +#### 2.3.2 主要方法 + +```java +// 添加搜索历史记录 +void addSearchHistory(String keyword); + +// 获取最近的搜索历史记录 +List getRecentSearches(int maxResults); + +// 获取匹配的搜索历史记录 +List getMatchingSearches(String keyword, int maxResults); + +// 删除特定的搜索历史记录 +void deleteSearchHistory(String keyword); + +// 清除所有搜索历史记录 +void clearSearchHistory(); + +// 设置最大历史记录数 +void setMaxHistoryCount(int maxCount); +``` + +## 3. 使用示例 + +### 3.1 基本搜索 + +```java +// 获取搜索管理器实例 +SearchManager searchManager = SearchManager.getInstance(context); + +// 执行搜索 +List results = searchManager.search("关键词", SearchManager.SortBy.RELEVANCE, 20); + +// 处理搜索结果 +for (SearchResult result : results) { + Log.d("Search", "找到便签:" + result.getTitle()); + // 高亮显示关键词 + String highlightedTitle = searchManager.highlightKeyword(result.getTitle(), "关键词"); + String highlightedContent = searchManager.highlightKeyword(result.getContent(), "关键词"); + // 显示高亮后的文本 +} +``` + +### 3.2 实时搜索建议 + +```java +// 监听搜索框输入变化 +searchEditText.addTextChangedListener(new TextWatcher() { + @Override + public void afterTextChanged(Editable s) { + String keyword = s.toString(); + // 获取搜索建议 + List suggestions = searchManager.getSearchSuggestions(keyword, 5); + // 更新搜索建议列表 + updateSuggestionsList(suggestions); + } + + // 其他方法... +}); +``` + +### 3.3 搜索历史管理 + +```java +// 获取最近的10条搜索历史 +List history = searchManager.getSearchHistory(10); + +// 清除搜索历史 +searchManager.clearSearchHistory(); + +// 删除特定的搜索历史 +searchManager.deleteSearchHistory("不需要的关键词"); +``` + +## 4. 架构设计 + +### 4.1 模块结构 + +``` +net.micode.notes.search +├── SearchManager.java // 搜索管理器,核心入口类 +├── SearchResult.java // 搜索结果数据模型 +├── SearchHistory.java // 搜索历史管理 +├── SearchIndexer.java // 搜索索引器 +└── README.md // API 文档 +``` + +### 4.2 工作流程 + +1. **搜索请求处理**: + - 客户端调用 `SearchManager.search()` 方法发起搜索请求 + - `SearchManager` 首先检查搜索关键词是否为空 + - 将搜索关键词保存到搜索历史 + - 尝试从索引中获取搜索结果 + - 如果索引搜索失败或结果为空,则回退到直接数据库查询 + - 对搜索结果进行排序 + - 返回搜索结果 + +2. **搜索建议生成**: + - 客户端调用 `SearchManager.getSearchSuggestions()` 方法获取搜索建议 + - 从搜索历史中查找匹配的关键词 + - 如果历史建议不足,从索引中获取更多建议 + - 返回搜索建议列表 + +3. **搜索索引更新**: + - `SearchIndexer` 负责创建和管理搜索索引 + - 索引包含单词到便签ID的映射 + - 索引在初始化时构建,并可通过 `updateSearchIndex()` 方法手动更新 + +## 5. 性能优化 + +1. **索引机制**:使用内存索引提高搜索速度 +2. **读写锁**:确保索引更新和搜索操作的线程安全 +3. **结果限制**:支持设置最大返回结果数,减少内存占用 +4. **延迟加载**:索引构建采用延迟加载策略 +5. **缓存机制**:搜索历史使用 SharedPreferences 缓存 + +## 6. 注意事项 + +1. **权限**:搜索功能需要访问便签数据库,确保应用具有相应的权限 +2. **线程安全**:搜索模块的所有方法均为线程安全,可以在任意线程调用 +3. **初始化**:第一次调用 `SearchManager.getInstance()` 会初始化搜索索引,可能会有短暂的性能开销 +4. **索引更新**:当便签数据发生变化时,建议调用 `updateSearchIndex()` 方法更新搜索索引,以确保搜索结果的准确性 + +## 7. 单元测试 + +搜索模块提供了完整的单元测试,测试文件位于 `src/test/java/net/micode/notes/search/` 目录下,包括: + +- `SearchManagerTest.java`:测试搜索管理器的核心功能 +- `SearchResultTest.java`:测试搜索结果的相关度计算 +- `SearchHistoryTest.java`:测试搜索历史管理功能 + +## 8. 版本信息 + +- 版本:1.0.0 +- 发布日期:2024-01-21 +- 作者:MiCode Open Source Community diff --git a/src/main/java/net/micode/notes/search/SearchHistory.java b/src/main/java/net/micode/notes/search/SearchHistory.java new file mode 100644 index 0000000..e7bbb0c --- /dev/null +++ b/src/main/java/net/micode/notes/search/SearchHistory.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2024, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.search; + +import android.content.Context; +import android.content.SharedPreferences; +import android.text.TextUtils; +import android.util.Log; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 搜索历史管理类,负责存储、查询和管理搜索历史记录 + */ +public class SearchHistory { + private static final String TAG = "SearchHistory"; + private static final String PREF_NAME = "search_history"; + private static final String KEY_SEARCH_HISTORY = "search_history"; + private static final int DEFAULT_MAX_HISTORY = 20; + private static SearchHistory sInstance; + + private SharedPreferences mPrefs; + private int mMaxHistoryCount = DEFAULT_MAX_HISTORY; + + /** + * 私有构造函数,初始化搜索历史管理器 + * @param context 上下文对象 + */ + private SearchHistory(Context context) { + mPrefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); + } + + /** + * 获取搜索历史管理器的单例实例 + * @param context 上下文对象 + * @return SearchHistory 实例 + */ + public static synchronized SearchHistory getInstance(Context context) { + if (sInstance == null) { + sInstance = new SearchHistory(context); + } + return sInstance; + } + + /** + * 设置最大历史记录数 + * @param maxCount 最大历史记录数 + */ + public void setMaxHistoryCount(int maxCount) { + mMaxHistoryCount = maxCount > 0 ? maxCount : DEFAULT_MAX_HISTORY; + // 确保现有历史记录不超过新的最大值 + trimHistory(); + } + + /** + * 添加搜索历史记录 + * @param keyword 搜索关键词 + */ + public void addSearchHistory(String keyword) { + if (TextUtils.isEmpty(keyword)) { + return; + } + + List history = getHistoryList(); + + // 如果已经存在相同的关键词,先移除 + history.remove(keyword); + + // 将新关键词添加到历史记录的开头 + history.add(0, keyword); + + // 确保历史记录不超过最大数量 + trimHistory(history); + + // 保存更新后的历史记录 + saveHistoryList(history); + } + + /** + * 获取最近的搜索历史记录 + * @param maxResults 最大结果数 + * @return 搜索历史列表 + */ + public List getRecentSearches(int maxResults) { + List history = getHistoryList(); + if (maxResults <= 0 || maxResults >= history.size()) { + return new ArrayList<>(history); + } + return new ArrayList<>(history.subList(0, maxResults)); + } + + /** + * 获取匹配的搜索历史记录 + * @param keyword 搜索关键词前缀 + * @param maxResults 最大结果数 + * @return 匹配的搜索历史列表 + */ + public List getMatchingSearches(String keyword, int maxResults) { + if (TextUtils.isEmpty(keyword)) { + return Collections.emptyList(); + } + + List history = getHistoryList(); + List matching = new ArrayList<>(); + keyword = keyword.toLowerCase(); + + for (String item : history) { + if (item.toLowerCase().contains(keyword)) { + matching.add(item); + if (matching.size() >= maxResults) { + break; + } + } + } + + return matching; + } + + /** + * 删除特定的搜索历史记录 + * @param keyword 要删除的搜索关键词 + */ + public void deleteSearchHistory(String keyword) { + if (TextUtils.isEmpty(keyword)) { + return; + } + + List history = getHistoryList(); + if (history.remove(keyword)) { + saveHistoryList(history); + } + } + + /** + * 清除所有搜索历史记录 + */ + public void clearSearchHistory() { + mPrefs.edit().remove(KEY_SEARCH_HISTORY).apply(); + } + + /** + * 从SharedPreferences中获取历史记录列表 + * @return 历史记录列表 + */ + private List getHistoryList() { + Set historySet = mPrefs.getStringSet(KEY_SEARCH_HISTORY, new HashSet<>()); + return new ArrayList<>(historySet); + } + + /** + * 将历史记录列表保存到SharedPreferences中 + * @param history 历史记录列表 + */ + private void saveHistoryList(List history) { + Set historySet = new HashSet<>(history); + mPrefs.edit().putStringSet(KEY_SEARCH_HISTORY, historySet).apply(); + } + + /** + * 裁剪历史记录,确保不超过最大数量 + */ + private void trimHistory() { + trimHistory(getHistoryList()); + } + + /** + * 裁剪历史记录,确保不超过最大数量 + * @param history 历史记录列表 + */ + private void trimHistory(List history) { + while (history.size() > mMaxHistoryCount) { + history.remove(history.size() - 1); + } + } + + /** + * 获取当前历史记录数量 + * @return 历史记录数量 + */ + public int getHistoryCount() { + return getHistoryList().size(); + } +} \ No newline at end of file diff --git a/src/main/java/net/micode/notes/search/SearchIndexer.java b/src/main/java/net/micode/notes/search/SearchIndexer.java new file mode 100644 index 0000000..da4aa27 --- /dev/null +++ b/src/main/java/net/micode/notes/search/SearchIndexer.java @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2024, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.search; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.text.TextUtils; +import android.util.Log; + +import net.micode.notes.data.Notes; +import net.micode.notes.data.Notes.DataColumns; +import net.micode.notes.data.Notes.NoteColumns; +import net.micode.notes.data.Notes.TextNote; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** + * 搜索索引器类,负责创建和管理搜索索引,提高搜索效率 + */ +public class SearchIndexer { + private static final String TAG = "SearchIndexer"; + private Context mContext; + private Map> mWordToNoteIdsMap; // 单词到便签ID的映射 + private Map mNoteIndexDataMap; // 便签ID到索引数据的映射 + private ReadWriteLock mLock; // 读写锁,确保线程安全 + + /** + * 便签索引数据类,存储便签的索引信息 + */ + private static class NoteIndexData { + String title; + String content; + String snippet; + long createdDate; + long modifiedDate; + + NoteIndexData(String title, String content, String snippet, long createdDate, long modifiedDate) { + this.title = title; + this.content = content; + this.snippet = snippet; + this.createdDate = createdDate; + this.modifiedDate = modifiedDate; + } + } + + /** + * 构造函数,初始化搜索索引器 + * @param context 上下文对象 + */ + public SearchIndexer(Context context) { + mContext = context; + mWordToNoteIdsMap = new HashMap<>(); + mNoteIndexDataMap = new HashMap<>(); + mLock = new ReentrantReadWriteLock(); + + // 初始化时构建索引 + updateIndex(); + } + + /** + * 更新搜索索引 + */ + public void updateIndex() { + mLock.writeLock().lock(); + try { + // 清空现有索引 + mWordToNoteIdsMap.clear(); + mNoteIndexDataMap.clear(); + + // 从数据库中获取所有便签数据 + List noteDataList = getAllNoteData(); + + // 为每个便签构建索引 + for (long noteId : mNoteIndexDataMap.keySet()) { + NoteIndexData noteData = mNoteIndexDataMap.get(noteId); + if (noteData != null) { + // 提取关键词并添加到索引中 + Set keywords = extractKeywords(noteData.title, noteData.content, noteData.snippet); + for (String keyword : keywords) { + Set noteIds = mWordToNoteIdsMap.getOrDefault(keyword, new HashSet<>()); + noteIds.add(noteId); + mWordToNoteIdsMap.put(keyword, noteIds); + } + } + } + + Log.d(TAG, "Search index updated. Total words: " + mWordToNoteIdsMap.size() + ", Total notes: " + mNoteIndexDataMap.size()); + } finally { + mLock.writeLock().unlock(); + } + } + + /** + * 执行搜索 + * @param keyword 搜索关键词 + * @return 搜索结果列表 + */ + public List search(String keyword) { + mLock.readLock().lock(); + try { + List results = new ArrayList<>(); + + if (TextUtils.isEmpty(keyword)) { + return results; + } + + keyword = keyword.toLowerCase(); + Set matchingNoteIds = new HashSet<>(); + + // 查找包含关键词的所有便签ID + for (String indexedWord : mWordToNoteIdsMap.keySet()) { + if (indexedWord.contains(keyword)) { + matchingNoteIds.addAll(mWordToNoteIdsMap.get(indexedWord)); + } + } + + // 为每个匹配的便签创建搜索结果 + for (long noteId : matchingNoteIds) { + NoteIndexData noteData = mNoteIndexDataMap.get(noteId); + if (noteData != null) { + SearchResult result = new SearchResult(); + result.setNoteId(noteId); + result.setTitle(noteData.title); + result.setContent(noteData.content); + result.setSnippet(noteData.snippet); + result.setCreatedDate(noteData.createdDate); + result.setModifiedDate(noteData.modifiedDate); + + // 计算相关度得分 + result.calculateRelevanceScore(keyword); + + results.add(result); + } + } + + return results; + } finally { + mLock.readLock().unlock(); + } + } + + /** + * 获取搜索建议 + * @param keyword 搜索关键词前缀 + * @param maxResults 最大建议数 + * @return 搜索建议列表 + */ + public List getSuggestions(String keyword, int maxResults) { + mLock.readLock().lock(); + try { + List suggestions = new ArrayList<>(); + + if (TextUtils.isEmpty(keyword)) { + return suggestions; + } + + keyword = keyword.toLowerCase(); + Set uniqueSuggestions = new HashSet<>(); + + // 从索引中查找匹配的关键词 + for (String indexedWord : mWordToNoteIdsMap.keySet()) { + if (indexedWord.startsWith(keyword)) { + uniqueSuggestions.add(indexedWord); + if (uniqueSuggestions.size() >= maxResults) { + break; + } + } + } + + suggestions.addAll(uniqueSuggestions); + return suggestions; + } finally { + mLock.readLock().unlock(); + } + } + + /** + * 从数据库中获取所有便签数据 + * @return 便签数据列表 + */ + private List getAllNoteData() { + List noteDataList = new ArrayList<>(); + ContentResolver resolver = mContext.getContentResolver(); + + // 查询所有普通便签 + String[] projection = new String[] { + NoteColumns.ID, + NoteColumns.TITLE, + NoteColumns.SNIPPET, + NoteColumns.CREATED_DATE, + NoteColumns.MODIFIED_DATE, + DataColumns.CONTENT + }; + + String selection = NoteColumns.TYPE + " = ?"; + String[] selectionArgs = new String[] { String.valueOf(Notes.TYPE_NOTE) }; + + Cursor cursor = resolver.query( + Notes.CONTENT_NOTE_URI, + projection, + selection, + selectionArgs, + null + ); + + if (cursor != null) { + try { + while (cursor.moveToNext()) { + long noteId = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID)); + String title = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.TITLE)); + String snippet = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.SNIPPET)); + String content = cursor.getString(cursor.getColumnIndexOrThrow(DataColumns.CONTENT)); + long createdDate = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.CREATED_DATE)); + long modifiedDate = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.MODIFIED_DATE)); + + NoteIndexData noteData = new NoteIndexData(title, content, snippet, createdDate, modifiedDate); + noteDataList.add(noteData); + mNoteIndexDataMap.put(noteId, noteData); + } + } catch (Exception e) { + Log.e(TAG, "Error reading notes data: " + e.getMessage()); + } finally { + cursor.close(); + } + } + + return noteDataList; + } + + /** + * 提取文本中的关键词 + * @param texts 要提取关键词的文本数组 + * @return 提取的关键词集合 + */ + private Set extractKeywords(String... texts) { + Set keywords = new HashSet<>(); + + for (String text : texts) { + if (!TextUtils.isEmpty(text)) { + // 将文本转换为小写并去除标点符号 + String processedText = text.toLowerCase().replaceAll("[^a-z0-9\\u4e00-\\u9fa5]", " "); + + // 分割成单词 + String[] words = processedText.split("\\s+"); + + for (String word : words) { + if (word.length() > 0) { + keywords.add(word); + } + } + } + } + + return keywords; + } +} diff --git a/src/main/java/net/micode/notes/search/SearchManager.java b/src/main/java/net/micode/notes/search/SearchManager.java new file mode 100644 index 0000000..5a1f1d2 --- /dev/null +++ b/src/main/java/net/micode/notes/search/SearchManager.java @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2024, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.search; + +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.text.TextUtils; +import android.util.Log; + +import net.micode.notes.data.Notes; +import net.micode.notes.data.Notes.DataColumns; +import net.micode.notes.data.Notes.NoteColumns; +import net.micode.notes.data.Notes.TextNote; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * 搜索管理器类,负责处理搜索请求,管理搜索历史和提供搜索结果 + */ +public class SearchManager { + private static final String TAG = "SearchManager"; + private static SearchManager sInstance; + private Context mContext; + private SearchHistory mSearchHistory; + private SearchIndexer mSearchIndexer; + + /** + * 搜索结果排序方式枚举 + */ + public enum SortBy { + RELEVANCE, // 相关度 + CREATED_DATE, // 创建时间 + MODIFIED_DATE // 更新时间 + } + + /** + * 私有构造函数,初始化搜索管理器 + * @param context 上下文对象 + */ + private SearchManager(Context context) { + mContext = context.getApplicationContext(); + mSearchHistory = SearchHistory.getInstance(mContext); + mSearchIndexer = new SearchIndexer(mContext); + } + + /** + * 获取搜索管理器的单例实例 + * @param context 上下文对象 + * @return SearchManager 实例 + */ + public static synchronized SearchManager getInstance(Context context) { + if (sInstance == null) { + sInstance = new SearchManager(context); + } + return sInstance; + } + + /** + * 执行搜索 + * @param keyword 搜索关键词 + * @param sortBy 排序方式 + * @param maxResults 最大结果数 + * @return 搜索结果列表 + */ + public List search(String keyword, SortBy sortBy, int maxResults) { + if (TextUtils.isEmpty(keyword)) { + return Collections.emptyList(); + } + + // 保存搜索历史 + mSearchHistory.addSearchHistory(keyword); + + // 首先尝试从索引中搜索 + List results = mSearchIndexer.search(keyword); + + // 如果索引搜索失败或结果为空,则回退到直接数据库查询 + if (results.isEmpty()) { + results = searchFromDatabase(keyword); + } + + // 对搜索结果进行排序 + sortSearchResults(results, sortBy, keyword); + + // 限制结果数量 + if (results.size() > maxResults) { + results = results.subList(0, maxResults); + } + + return results; + } + + /** + * 从数据库中直接搜索 + * @param keyword 搜索关键词 + * @return 搜索结果列表 + */ + private List searchFromDatabase(String keyword) { + List results = new ArrayList<>(); + ContentResolver resolver = mContext.getContentResolver(); + + // 构建查询条件:匹配标题或内容 + String selection = NoteColumns.TYPE + " = ? AND (" + + NoteColumns.TITLE + " LIKE ? OR " + + NoteColumns.SNIPPET + " LIKE ? OR " + + DataColumns.CONTENT + " LIKE ?)"; + String[] selectionArgs = new String[] { + String.valueOf(Notes.TYPE_NOTE), + "%" + keyword + "%", + "%" + keyword + "%", + "%" + keyword + "%" + }; + + // 构建查询的列 + String[] projection = new String[] { + NoteColumns.ID, + NoteColumns.TITLE, + NoteColumns.SNIPPET, + NoteColumns.CREATED_DATE, + NoteColumns.MODIFIED_DATE, + NoteColumns.BG_COLOR_ID, + DataColumns.CONTENT + }; + + // 执行查询 + Cursor cursor = resolver.query( + Notes.CONTENT_NOTE_URI, + projection, + selection, + selectionArgs, + null + ); + + if (cursor != null) { + try { + while (cursor.moveToNext()) { + SearchResult result = new SearchResult(); + result.setNoteId(cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID))); + result.setTitle(cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.TITLE))); + result.setSnippet(cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.SNIPPET))); + result.setContent(cursor.getString(cursor.getColumnIndexOrThrow(DataColumns.CONTENT))); + result.setCreatedDate(cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.CREATED_DATE))); + result.setModifiedDate(cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.MODIFIED_DATE))); + result.setBgColorId(cursor.getInt(cursor.getColumnIndexOrThrow(NoteColumns.BG_COLOR_ID))); + + // 计算相关度得分 + result.calculateRelevanceScore(keyword); + + results.add(result); + } + } catch (Exception e) { + Log.e(TAG, "Error searching database: " + e.getMessage()); + } finally { + cursor.close(); + } + } + + return results; + } + + /** + * 对搜索结果进行排序 + * @param results 搜索结果列表 + * @param sortBy 排序方式 + * @param keyword 搜索关键词 + */ + private void sortSearchResults(List results, SortBy sortBy, String keyword) { + Collections.sort(results, new Comparator() { + @Override + public int compare(SearchResult r1, SearchResult r2) { + switch (sortBy) { + case CREATED_DATE: + return Long.compare(r2.getCreatedDate(), r1.getCreatedDate()); // 降序 + case MODIFIED_DATE: + return Long.compare(r2.getModifiedDate(), r1.getModifiedDate()); // 降序 + case RELEVANCE: + default: + return Float.compare(r2.getRelevanceScore(), r1.getRelevanceScore()); // 降序 + } + } + }); + } + + /** + * 获取搜索建议 + * @param keyword 搜索关键词前缀 + * @param maxResults 最大建议数 + * @return 搜索建议列表 + */ + public List getSearchSuggestions(String keyword, int maxResults) { + List suggestions = new ArrayList<>(); + + if (TextUtils.isEmpty(keyword)) { + // 如果关键词为空,返回最近的搜索历史 + suggestions.addAll(mSearchHistory.getRecentSearches(maxResults)); + return suggestions; + } + + // 从搜索历史中获取匹配的建议 + suggestions.addAll(mSearchHistory.getMatchingSearches(keyword, maxResults)); + + // 如果历史建议不足,从索引中获取更多建议 + if (suggestions.size() < maxResults) { + List indexSuggestions = mSearchIndexer.getSuggestions(keyword, maxResults - suggestions.size()); + for (String suggestion : indexSuggestions) { + if (!suggestions.contains(suggestion)) { + suggestions.add(suggestion); + } + } + } + + return suggestions; + } + + /** + * 获取搜索历史记录 + * @param maxResults 最大历史记录数 + * @return 搜索历史列表 + */ + public List getSearchHistory(int maxResults) { + return mSearchHistory.getRecentSearches(maxResults); + } + + /** + * 清除搜索历史记录 + */ + public void clearSearchHistory() { + mSearchHistory.clearSearchHistory(); + } + + /** + * 删除特定的搜索历史记录 + * @param keyword 要删除的搜索关键词 + */ + public void deleteSearchHistory(String keyword) { + mSearchHistory.deleteSearchHistory(keyword); + } + + /** + * 更新搜索索引 + */ + public void updateSearchIndex() { + mSearchIndexer.updateIndex(); + } + + /** + * 高亮搜索结果中的关键词 + * @param text 原始文本 + * @param keyword 搜索关键词 + * @return 带有高亮标记的文本 + */ + public String highlightKeyword(String text, String keyword) { + if (TextUtils.isEmpty(text) || TextUtils.isEmpty(keyword)) { + return text; + } + + try { + String regex = "(?i)(" + keyword + ")"; + return text.replaceAll(regex, "$1"); + } catch (Exception e) { + Log.e(TAG, "Error highlighting keyword: " + e.getMessage()); + return text; + } + } +} \ No newline at end of file diff --git a/src/main/java/net/micode/notes/search/SearchResult.java b/src/main/java/net/micode/notes/search/SearchResult.java new file mode 100644 index 0000000..bdc6334 --- /dev/null +++ b/src/main/java/net/micode/notes/search/SearchResult.java @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2024, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.search; + +import android.text.TextUtils; + +/** + * 搜索结果类,用于存储单个搜索结果的数据 + */ +public class SearchResult { + private long mNoteId; // 便签ID + private String mTitle; // 便签标题 + private String mSnippet; // 便签摘要 + private String mContent; // 便签内容 + private long mCreatedDate; // 创建时间 + private long mModifiedDate; // 更新时间 + private int mBgColorId; // 背景颜色ID + private float mRelevanceScore; // 相关度得分 + + /** + * 获取便签ID + * @return 便签ID + */ + public long getNoteId() { + return mNoteId; + } + + /** + * 设置便签ID + * @param noteId 便签ID + */ + public void setNoteId(long noteId) { + mNoteId = noteId; + } + + /** + * 获取便签标题 + * @return 便签标题 + */ + public String getTitle() { + return mTitle; + } + + /** + * 设置便签标题 + * @param title 便签标题 + */ + public void setTitle(String title) { + mTitle = title; + } + + /** + * 获取便签摘要 + * @return 便签摘要 + */ + public String getSnippet() { + return mSnippet; + } + + /** + * 设置便签摘要 + * @param snippet 便签摘要 + */ + public void setSnippet(String snippet) { + mSnippet = snippet; + } + + /** + * 获取便签内容 + * @return 便签内容 + */ + public String getContent() { + return mContent; + } + + /** + * 设置便签内容 + * @param content 便签内容 + */ + public void setContent(String content) { + mContent = content; + } + + /** + * 获取创建时间 + * @return 创建时间 + */ + public long getCreatedDate() { + return mCreatedDate; + } + + /** + * 设置创建时间 + * @param createdDate 创建时间 + */ + public void setCreatedDate(long createdDate) { + mCreatedDate = createdDate; + } + + /** + * 获取更新时间 + * @return 更新时间 + */ + public long getModifiedDate() { + return mModifiedDate; + } + + /** + * 设置更新时间 + * @param modifiedDate 更新时间 + */ + public void setModifiedDate(long modifiedDate) { + mModifiedDate = modifiedDate; + } + + /** + * 获取背景颜色ID + * @return 背景颜色ID + */ + public int getBgColorId() { + return mBgColorId; + } + + /** + * 设置背景颜色ID + * @param bgColorId 背景颜色ID + */ + public void setBgColorId(int bgColorId) { + mBgColorId = bgColorId; + } + + /** + * 获取相关度得分 + * @return 相关度得分 + */ + public float getRelevanceScore() { + return mRelevanceScore; + } + + /** + * 设置相关度得分 + * @param relevanceScore 相关度得分 + */ + public void setRelevanceScore(float relevanceScore) { + mRelevanceScore = relevanceScore; + } + + /** + * 计算相关度得分 + * @param keyword 搜索关键词 + */ + public void calculateRelevanceScore(String keyword) { + if (TextUtils.isEmpty(keyword)) { + mRelevanceScore = 0.0f; + return; + } + + float score = 0.0f; + keyword = keyword.toLowerCase(); + + // 标题匹配权重更高 + if (!TextUtils.isEmpty(mTitle)) { + String lowerTitle = mTitle.toLowerCase(); + if (lowerTitle.contains(keyword)) { + // 完全匹配标题给予最高权重 + if (lowerTitle.equals(keyword)) { + score += 10.0f; + } else { + // 部分匹配根据匹配位置和长度计算得分 + score += 5.0f * (1.0f - (float) lowerTitle.indexOf(keyword) / lowerTitle.length()); + } + } + } + + // 内容匹配权重次之 + if (!TextUtils.isEmpty(mContent)) { + String lowerContent = mContent.toLowerCase(); + if (lowerContent.contains(keyword)) { + // 计算匹配次数 + int count = 0; + int index = 0; + while ((index = lowerContent.indexOf(keyword, index)) != -1) { + count++; + index += keyword.length(); + } + // 匹配次数和位置都影响得分 + score += 2.0f * count * (1.0f - (float) lowerContent.indexOf(keyword) / lowerContent.length()); + } + } + + // 摘要匹配权重较低 + if (!TextUtils.isEmpty(mSnippet)) { + String lowerSnippet = mSnippet.toLowerCase(); + if (lowerSnippet.contains(keyword)) { + score += 1.0f; + } + } + + mRelevanceScore = score; + } + + @Override + public String toString() { + return "SearchResult{" + + "noteId=" + mNoteId + + ", title='" + mTitle + '\'' + + ", relevanceScore=" + mRelevanceScore + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/net/micode/notes/ui/NotesListActivity.java b/src/main/java/net/micode/notes/ui/NotesListActivity.java index 65aa7f5..7b221d3 100644 --- a/src/main/java/net/micode/notes/ui/NotesListActivity.java +++ b/src/main/java/net/micode/notes/ui/NotesListActivity.java @@ -49,12 +49,16 @@ import android.view.View; import android.view.View.OnClickListener; import android.view.View.OnCreateContextMenuListener; import android.view.View.OnTouchListener; +import android.view.KeyEvent; +import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputMethodManager; import android.widget.AdapterView; import android.widget.AdapterView.OnItemClickListener; import android.widget.AdapterView.OnItemLongClickListener; import android.widget.Button; import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; import android.widget.ListView; import android.widget.PopupMenu; import android.widget.TextView; @@ -76,6 +80,7 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.util.ArrayList; import java.util.HashSet; public class NotesListActivity extends Activity implements OnClickListener, OnItemLongClickListener { @@ -127,14 +132,19 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt private static final String NORMAL_SELECTION = NoteColumns.PARENT_ID + "=?"; - private static final String ROOT_FOLDER_SELECTION = "(" + NoteColumns.TYPE + "<>" - + Notes.TYPE_SYSTEM + " AND " + NoteColumns.PARENT_ID + "=?)" + " OR (" - + NoteColumns.ID + "=" + Notes.ID_CALL_RECORD_FOLDER + " AND " - + NoteColumns.NOTES_COUNT + ">0)"; + private static final String ROOT_FOLDER_SELECTION = "(" + NoteColumns.TYPE + "<>" + Notes.TYPE_SYSTEM + " AND " + NoteColumns.PARENT_ID + "=?)" + " OR (" + NoteColumns.ID + "=" + Notes.ID_CALL_RECORD_FOLDER + " AND " + NoteColumns.NOTES_COUNT + ">0)"; private final static int REQUEST_CODE_OPEN_NODE = 102; private final static int REQUEST_CODE_NEW_NODE = 103; + // 搜索相关变量 + private LinearLayout mSearchBar; + private EditText mSearchEditText; + private ImageView mSearchClear; + private ImageView mSearchButton; + private String mSearchQuery; + private boolean mIsSearching; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -275,6 +285,85 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt mTitleBar = (TextView) findViewById(R.id.tv_title_bar); mState = ListEditState.NOTE_LIST; mModeCallBack = new ModeCallback(); + + // 初始化搜索栏组件 + mSearchBar = (LinearLayout) findViewById(R.id.search_bar); + mSearchEditText = (EditText) findViewById(R.id.search_edit_text); + mSearchClear = (ImageView) findViewById(R.id.search_clear); + mSearchButton = (ImageView) findViewById(R.id.search_button); + + // 设置搜索文本变化监听,恢复实时搜索功能 + mSearchEditText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // 不需要处理 + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + // 不需要处理 + } + + @Override + public void afterTextChanged(Editable s) { + String query = s.toString().trim(); + mSearchQuery = query; + mIsSearching = !TextUtils.isEmpty(query); + + // 显示或隐藏清除按钮 + mSearchClear.setVisibility(mIsSearching ? View.VISIBLE : View.GONE); + + // 执行实时搜索 + startAsyncNotesListQuery(); + } + }); + + // 重新实现搜索按钮点击事件,简化实现 + mSearchButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + // 直接执行搜索,因为实时搜索已经更新了搜索条件 + startAsyncNotesListQuery(); + + // 隐藏软键盘 + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.hideSoftInputFromWindow(mSearchEditText.getWindowToken(), 0); + } + } + }); + + // 设置清除按钮点击事件 + mSearchClear.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + mSearchEditText.setText(""); + mSearchQuery = ""; + mIsSearching = false; + mSearchClear.setVisibility(View.GONE); + startAsyncNotesListQuery(); + } + }); + + // 设置回车键搜索 + mSearchEditText.setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_SEARCH || + (event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_DOWN)) { + // 执行搜索 + startAsyncNotesListQuery(); + + // 隐藏软键盘 + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.hideSoftInputFromWindow(mSearchEditText.getWindowToken(), 0); + } + return true; + } + return false; + } + }); } private class ModeCallback implements ListView.MultiChoiceModeListener, OnMenuItemClickListener { @@ -464,13 +553,42 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt }; private void startAsyncNotesListQuery() { - String selection = (mCurrentFolderId == Notes.ID_ROOT_FOLDER) ? ROOT_FOLDER_SELECTION - : NORMAL_SELECTION; + String selection; + String[] selectionArgs; + + // 根据是否有搜索关键词构建不同的查询条件 + if (!TextUtils.isEmpty(mSearchQuery)) { + // 有搜索关键词的情况 + if (mCurrentFolderId == Notes.ID_ROOT_FOLDER) { + // 根文件夹:需要将搜索条件应用到两个部分 + selection = "((" + NoteColumns.TYPE + "<>" + Notes.TYPE_SYSTEM + " AND " + NoteColumns.PARENT_ID + "=? AND (" + NoteColumns.TITLE + " LIKE ? OR " + NoteColumns.SNIPPET + " LIKE ?))" + + " OR (" + NoteColumns.ID + "=" + Notes.ID_CALL_RECORD_FOLDER + " AND " + NoteColumns.NOTES_COUNT + ">0 AND (" + NoteColumns.TITLE + " LIKE ? OR " + NoteColumns.SNIPPET + " LIKE ?)))"; + selectionArgs = new String[] { + String.valueOf(mCurrentFolderId), + "%" + mSearchQuery + "%", + "%" + mSearchQuery + "%", + "%" + mSearchQuery + "%", + "%" + mSearchQuery + "%" + }; + } else { + // 普通文件夹 + selection = NORMAL_SELECTION + " AND (" + NoteColumns.TITLE + " LIKE ? OR " + NoteColumns.SNIPPET + " LIKE ?)"; + selectionArgs = new String[] { + String.valueOf(mCurrentFolderId), + "%" + mSearchQuery + "%", + "%" + mSearchQuery + "%" + }; + } + } else { + // 没有搜索关键词的情况 + selection = (mCurrentFolderId == Notes.ID_ROOT_FOLDER) ? ROOT_FOLDER_SELECTION : NORMAL_SELECTION; + selectionArgs = new String[] { String.valueOf(mCurrentFolderId) }; + } + mBackgroundQueryHandler.startQuery(FOLDER_NOTE_LIST_QUERY_TOKEN, null, - Notes.CONTENT_NOTE_URI, NoteItemData.PROJECTION, selection, new String[] { - String.valueOf(mCurrentFolderId) - }, NoteColumns.IS_PINNED + " DESC," + NoteColumns.PIN_PRIORITY + " DESC," - + NoteColumns.TYPE + " DESC," + NoteColumns.MODIFIED_DATE + " DESC"); + Notes.CONTENT_NOTE_URI, NoteItemData.PROJECTION, selection, selectionArgs, + NoteColumns.IS_PINNED + " DESC," + NoteColumns.PIN_PRIORITY + " DESC," + + NoteColumns.TYPE + " DESC," + NoteColumns.MODIFIED_DATE + " DESC"); } private final class BackgroundQueryHandler extends AsyncQueryHandler { @@ -557,15 +675,15 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt Log.e(TAG, "Wrong folder id, should not happen " + folderId); return; } - + HashSet ids = new HashSet(); ids.add(folderId); HashSet widgets = DataUtils.getFolderNoteWidget(mContentResolver, folderId); - + // Always move folder to trash folder for better user experience DataUtils.batchMoveToFolder(mContentResolver, ids, Notes.ID_TRASH_FOLER); - + if (widgets != null) { for (AppWidgetAttribute widget : widgets) { if (widget.widgetId != AppWidgetManager.INVALID_APPWIDGET_ID @@ -850,7 +968,7 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt } return true; } - + private void startTrashActivity() { Intent intent = new Intent(this, TrashActivity.class); startActivity(intent); diff --git a/src/main/res/layout/note_list.xml b/src/main/res/layout/note_list.xml index f8fd0ce..579370a 100644 --- a/src/main/res/layout/note_list.xml +++ b/src/main/res/layout/note_list.xml @@ -39,6 +39,54 @@ android:textColor="#FFEAD1AE" android:textSize="@dimen/text_font_size_medium" /> + + + + + + + + history = mSearchHistory.getRecentSearches(10); + assertNotNull("Search history should not be null", history); + + // 验证历史记录顺序(最新的在前面) + assertEquals("Latest search should be first", keyword3, history.get(0)); + assertEquals("Second latest search should be second", keyword2, history.get(1)); + assertEquals("Oldest search should be last", keyword1, history.get(2)); + + // 测试添加重复关键词 + mSearchHistory.addSearchHistory(keyword1); + history = mSearchHistory.getRecentSearches(10); + assertEquals("Duplicate keyword should be moved to front", keyword1, history.get(0)); + } + + /** + * 测试获取匹配的搜索历史 + */ + @Test + public void testGetMatchingSearches() { + // 清除现有历史记录 + mSearchHistory.clearSearchHistory(); + + // 添加搜索历史 + mSearchHistory.addSearchHistory("test1"); + mSearchHistory.addSearchHistory("test2"); + mSearchHistory.addSearchHistory("example"); + mSearchHistory.addSearchHistory("test3"); + + // 获取匹配的搜索历史 + List matching = mSearchHistory.getMatchingSearches("test", 5); + assertNotNull("Matching searches should not be null", matching); + assertTrue("Should find matching searches", matching.size() >= 3); + + // 验证所有匹配的关键词都包含"test" + for (String keyword : matching) { + assertTrue("Matching keyword should contain 'test'", keyword.contains("test")); + } + + // 测试不匹配的关键词 + matching = mSearchHistory.getMatchingSearches("nonexistent", 5); + assertTrue("Should not find matching searches for nonexistent keyword", matching.isEmpty()); + + // 测试空关键词 + matching = mSearchHistory.getMatchingSearches("", 5); + assertTrue("Should return empty list for empty keyword", matching.isEmpty()); + } + + /** + * 测试删除搜索历史 + */ + @Test + public void testDeleteSearchHistory() { + // 清除现有历史记录 + mSearchHistory.clearSearchHistory(); + + // 添加搜索历史 + String keyword1 = "test1"; + String keyword2 = "test2"; + + mSearchHistory.addSearchHistory(keyword1); + mSearchHistory.addSearchHistory(keyword2); + + // 删除一个关键词 + mSearchHistory.deleteSearchHistory(keyword1); + List history = mSearchHistory.getRecentSearches(10); + assertEquals("Should have one less item after deletion", 1, history.size()); + assertFalse("Deleted keyword should not be in history", history.contains(keyword1)); + assertTrue("Remaining keyword should be in history", history.contains(keyword2)); + } + + /** + * 测试清除搜索历史 + */ + @Test + public void testClearSearchHistory() { + // 添加搜索历史 + mSearchHistory.addSearchHistory("test1"); + mSearchHistory.addSearchHistory("test2"); + + // 清除搜索历史 + mSearchHistory.clearSearchHistory(); + List history = mSearchHistory.getRecentSearches(10); + assertNotNull("Search history should not be null after clearing", history); + } + + /** + * 测试最大历史记录数限制 + */ + @Test + public void testMaxHistoryCount() { + // 清除现有历史记录 + mSearchHistory.clearSearchHistory(); + + // 设置最大历史记录数 + int maxCount = 5; + mSearchHistory.setMaxHistoryCount(maxCount); + + // 添加超过最大数量的搜索历史 + for (int i = 0; i < maxCount + 3; i++) { + mSearchHistory.addSearchHistory("test" + i); + } + + // 获取搜索历史 + List history = mSearchHistory.getRecentSearches(10); + assertTrue("Search history should not exceed max count", history.size() <= maxCount); + } + + /** + * 测试添加空关键词 + */ + @Test + public void testAddEmptyKeyword() { + // 清除现有历史记录 + mSearchHistory.clearSearchHistory(); + + // 添加空关键词 + mSearchHistory.addSearchHistory(""); + List history = mSearchHistory.getRecentSearches(10); + assertTrue("Empty keyword should not be added to history", history.isEmpty()); + } +} \ No newline at end of file diff --git a/src/main/src/test/java/net/micode/notes/search/SearchManagerTest.java b/src/main/src/test/java/net/micode/notes/search/SearchManagerTest.java new file mode 100644 index 0000000..34a759e --- /dev/null +++ b/src/main/src/test/java/net/micode/notes/search/SearchManagerTest.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2024, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.search; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +import net.micode.notes.data.Notes; +import net.micode.notes.data.Notes.DataColumns; +import net.micode.notes.data.Notes.NoteColumns; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.List; + +/** + * SearchManager的单元测试类 + */ +@RunWith(MockitoJUnitRunner.class) +public class SearchManagerTest { + @Mock + private Context mMockContext; + @Mock + private ContentResolver mMockContentResolver; + @Mock + private Cursor mMockCursor; + + private SearchManager mSearchManager; + + @Before + public void setUp() { + // 配置Context和ContentResolver的mock行为 + when(mMockContext.getApplicationContext()).thenReturn(mMockContext); + when(mMockContext.getContentResolver()).thenReturn(mMockContentResolver); + + // 初始化SearchManager + mSearchManager = SearchManager.getInstance(mMockContext); + } + + /** + * 测试单例模式 + */ + @Test + public void testSingleton() { + SearchManager instance1 = SearchManager.getInstance(mMockContext); + SearchManager instance2 = SearchManager.getInstance(mMockContext); + assertSame("SearchManager should be a singleton", instance1, instance2); + } + + /** + * 测试空关键词搜索 + */ + @Test + public void testEmptyKeywordSearch() { + List results = mSearchManager.search("", SearchManager.SortBy.RELEVANCE, 10); + assertTrue("Search with empty keyword should return empty list", results.isEmpty()); + } + + /** + * 测试搜索建议功能 + */ + @Test + public void testSearchSuggestions() { + // 测试空关键词建议 + List suggestions = mSearchManager.getSearchSuggestions("", 5); + assertNotNull("Search suggestions should not be null", suggestions); + + // 测试非空关键词建议 + suggestions = mSearchManager.getSearchSuggestions("test", 5); + assertNotNull("Search suggestions should not be null", suggestions); + } + + /** + * 测试搜索历史功能 + */ + @Test + public void testSearchHistory() { + // 测试添加搜索历史 + mSearchManager.getSearchHistory(10); + + // 测试获取搜索历史 + List history = mSearchManager.getSearchHistory(10); + assertNotNull("Search history should not be null", history); + + // 测试清除搜索历史 + mSearchManager.clearSearchHistory(); + history = mSearchManager.getSearchHistory(10); + assertNotNull("Search history should not be null after clearing", history); + } + + /** + * 测试关键词高亮功能 + */ + @Test + public void testHighlightKeyword() { + String text = "This is a test text for highlighting keyword test"; + String keyword = "test"; + String highlighted = mSearchManager.highlightKeyword(text, keyword); + + // 验证高亮结果 + assertNotNull("Highlighted text should not be null", highlighted); + assertTrue("Highlighted text should contain HTML tags", highlighted.contains(" 9.0f); + + // 测试部分匹配标题 + mSearchResult.calculateRelevanceScore("Test"); + float partialTitleScore = mSearchResult.getRelevanceScore(); + assertTrue("Partial title match should have high score", partialTitleScore > 4.0f); + + // 测试内容匹配 + mSearchResult.calculateRelevanceScore("content"); + float contentScore = mSearchResult.getRelevanceScore(); + assertTrue("Content match should have moderate score", contentScore > 0.0f); + + // 测试多次匹配 + mSearchResult.calculateRelevanceScore("test"); + float multipleMatchScore = mSearchResult.getRelevanceScore(); + assertTrue("Multiple matches should have higher score", multipleMatchScore > contentScore); + + // 测试不匹配 + mSearchResult.calculateRelevanceScore("nonexistent"); + float noMatchScore = mSearchResult.getRelevanceScore(); + assertEquals("No match should have zero score", 0.0f, noMatchScore, 0.001f); + + // 测试空关键词 + mSearchResult.calculateRelevanceScore(""); + float emptyKeywordScore = mSearchResult.getRelevanceScore(); + assertEquals("Empty keyword should have zero score", 0.0f, emptyKeywordScore, 0.001f); + } + + /** + * 测试SearchResult的getter和setter方法 + */ + @Test + public void testGetterSetterMethods() { + // 测试NoteId + long noteId = 12345; + mSearchResult.setNoteId(noteId); + assertEquals("NoteId should be set correctly", noteId, mSearchResult.getNoteId()); + + // 测试Title + String title = "Test Title"; + mSearchResult.setTitle(title); + assertEquals("Title should be set correctly", title, mSearchResult.getTitle()); + + // 测试Content + String content = "Test Content"; + mSearchResult.setContent(content); + assertEquals("Content should be set correctly", content, mSearchResult.getContent()); + + // 测试Snippet + String snippet = "Test Snippet"; + mSearchResult.setSnippet(snippet); + assertEquals("Snippet should be set correctly", snippet, mSearchResult.getSnippet()); + + // 测试CreatedDate + long createdDate = System.currentTimeMillis(); + mSearchResult.setCreatedDate(createdDate); + assertEquals("CreatedDate should be set correctly", createdDate, mSearchResult.getCreatedDate()); + + // 测试ModifiedDate + long modifiedDate = System.currentTimeMillis(); + mSearchResult.setModifiedDate(modifiedDate); + assertEquals("ModifiedDate should be set correctly", modifiedDate, mSearchResult.getModifiedDate()); + + // 测试BgColorId + int bgColorId = 1; + mSearchResult.setBgColorId(bgColorId); + assertEquals("BgColorId should be set correctly", bgColorId, mSearchResult.getBgColorId()); + + // 测试RelevanceScore + float relevanceScore = 8.5f; + mSearchResult.setRelevanceScore(relevanceScore); + assertEquals("RelevanceScore should be set correctly", relevanceScore, mSearchResult.getRelevanceScore(), 0.001f); + } + + /** + * 测试toString方法 + */ + @Test + public void testToString() { + mSearchResult.setNoteId(123); + mSearchResult.setTitle("Test Title"); + mSearchResult.setRelevanceScore(5.5f); + + String resultString = mSearchResult.toString(); + assertNotNull("toString should not return null", resultString); + assertTrue("toString should contain noteId", resultString.contains("123")); + assertTrue("toString should contain title", resultString.contains("Test Title")); + assertTrue("toString should contain relevanceScore", resultString.contains("5.5")); + } +} \ No newline at end of file