新增搜索功能 #12

Merged
psq5hzxpo merged 1 commits from luhaozhe_branch into master 1 month ago

@ -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<SearchResult> search(String keyword, SortBy sortBy, int maxResults);
```
**参数说明:**
- `keyword`:搜索关键词
- `sortBy`:排序方式,可选值:
- `RELEVANCE`:按相关度排序
- `CREATED_DATE`:按创建时间排序
- `MODIFIED_DATE`:按更新时间排序
- `maxResults`:最大返回结果数
**返回值:**
- 搜索结果列表,每个元素为 `SearchResult` 对象
#### 2.1.3 获取搜索建议
```java
List<String> getSearchSuggestions(String keyword, int maxResults);
```
**参数说明:**
- `keyword`:搜索关键词前缀
- `maxResults`:最大返回建议数
**返回值:**
- 搜索建议列表
#### 2.1.4 搜索历史管理
```java
// 获取最近的搜索历史
List<String> 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<String> getRecentSearches(int maxResults);
// 获取匹配的搜索历史记录
List<String> 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<SearchResult> 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<String> suggestions = searchManager.getSearchSuggestions(keyword, 5);
// 更新搜索建议列表
updateSuggestionsList(suggestions);
}
// 其他方法...
});
```
### 3.3 搜索历史管理
```java
// 获取最近的10条搜索历史
List<String> 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

@ -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<String> history = getHistoryList();
// 如果已经存在相同的关键词,先移除
history.remove(keyword);
// 将新关键词添加到历史记录的开头
history.add(0, keyword);
// 确保历史记录不超过最大数量
trimHistory(history);
// 保存更新后的历史记录
saveHistoryList(history);
}
/**
*
* @param maxResults
* @return
*/
public List<String> getRecentSearches(int maxResults) {
List<String> 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<String> getMatchingSearches(String keyword, int maxResults) {
if (TextUtils.isEmpty(keyword)) {
return Collections.emptyList();
}
List<String> history = getHistoryList();
List<String> 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<String> history = getHistoryList();
if (history.remove(keyword)) {
saveHistoryList(history);
}
}
/**
*
*/
public void clearSearchHistory() {
mPrefs.edit().remove(KEY_SEARCH_HISTORY).apply();
}
/**
* SharedPreferences
* @return
*/
private List<String> getHistoryList() {
Set<String> historySet = mPrefs.getStringSet(KEY_SEARCH_HISTORY, new HashSet<>());
return new ArrayList<>(historySet);
}
/**
* SharedPreferences
* @param history
*/
private void saveHistoryList(List<String> history) {
Set<String> historySet = new HashSet<>(history);
mPrefs.edit().putStringSet(KEY_SEARCH_HISTORY, historySet).apply();
}
/**
*
*/
private void trimHistory() {
trimHistory(getHistoryList());
}
/**
*
* @param history
*/
private void trimHistory(List<String> history) {
while (history.size() > mMaxHistoryCount) {
history.remove(history.size() - 1);
}
}
/**
*
* @return
*/
public int getHistoryCount() {
return getHistoryList().size();
}
}

@ -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<String, Set<Long>> mWordToNoteIdsMap; // 单词到便签ID的映射
private Map<Long, NoteIndexData> 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<NoteIndexData> noteDataList = getAllNoteData();
// 为每个便签构建索引
for (long noteId : mNoteIndexDataMap.keySet()) {
NoteIndexData noteData = mNoteIndexDataMap.get(noteId);
if (noteData != null) {
// 提取关键词并添加到索引中
Set<String> keywords = extractKeywords(noteData.title, noteData.content, noteData.snippet);
for (String keyword : keywords) {
Set<Long> 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<SearchResult> search(String keyword) {
mLock.readLock().lock();
try {
List<SearchResult> results = new ArrayList<>();
if (TextUtils.isEmpty(keyword)) {
return results;
}
keyword = keyword.toLowerCase();
Set<Long> 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<String> getSuggestions(String keyword, int maxResults) {
mLock.readLock().lock();
try {
List<String> suggestions = new ArrayList<>();
if (TextUtils.isEmpty(keyword)) {
return suggestions;
}
keyword = keyword.toLowerCase();
Set<String> 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<NoteIndexData> getAllNoteData() {
List<NoteIndexData> 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<String> extractKeywords(String... texts) {
Set<String> 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;
}
}

@ -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<SearchResult> search(String keyword, SortBy sortBy, int maxResults) {
if (TextUtils.isEmpty(keyword)) {
return Collections.emptyList();
}
// 保存搜索历史
mSearchHistory.addSearchHistory(keyword);
// 首先尝试从索引中搜索
List<SearchResult> 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<SearchResult> searchFromDatabase(String keyword) {
List<SearchResult> 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<SearchResult> results, SortBy sortBy, String keyword) {
Collections.sort(results, new Comparator<SearchResult>() {
@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<String> getSearchSuggestions(String keyword, int maxResults) {
List<String> 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<String> 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<String> 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, "<font color=\"#FF4081\">$1</font>");
} catch (Exception e) {
Log.e(TAG, "Error highlighting keyword: " + e.getMessage());
return text;
}
}
}

@ -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 +
'}';
}
}

@ -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<Long> ids = new HashSet<Long>();
ids.add(folderId);
HashSet<AppWidgetAttribute> 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);

@ -39,6 +39,54 @@
android:textColor="#FFEAD1AE"
android:textSize="@dimen/text_font_size_medium" />
<!-- 搜索栏 -->
<LinearLayout
android:id="@+id/search_bar"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:padding="8dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:id="@+id/search_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@android:drawable/ic_menu_search"
android:tint="#808080"
android:layout_marginRight="8dp" />
<EditText
android:id="@+id/search_edit_text"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:background="@null"
android:hint="搜索便签..."
android:textSize="16sp"
android:singleLine="true"
android:imeOptions="actionSearch"
android:inputType="text"
android:textColorHint="#808080"
android:textColor="#000000" />
<ImageView
android:id="@+id/search_clear"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:tint="#808080"
android:visibility="gone" />
<ImageView
android:id="@+id/search_button"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@android:drawable/ic_menu_search"
android:tint="#808080"
android:layout_marginLeft="8dp"
android:clickable="true"
android:focusable="true"
android:contentDescription="搜索" />
</LinearLayout>
<ListView
android:id="@+id/notes_list"
android:layout_width="fill_parent"

@ -0,0 +1,194 @@
/*
* 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 android.content.Context;
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;
/**
* SearchHistory
*/
@RunWith(MockitoJUnitRunner.class)
public class SearchHistoryTest {
@Mock
private Context mMockContext;
private SearchHistory mSearchHistory;
@Before
public void setUp() {
// 初始化SearchHistory
mSearchHistory = SearchHistory.getInstance(mMockContext);
}
/**
*
*/
@Test
public void testSingleton() {
SearchHistory instance1 = SearchHistory.getInstance(mMockContext);
SearchHistory instance2 = SearchHistory.getInstance(mMockContext);
assertSame("SearchHistory should be a singleton", instance1, instance2);
}
/**
*
*/
@Test
public void testAddAndGetSearchHistory() {
// 清除现有历史记录
mSearchHistory.clearSearchHistory();
// 添加搜索历史
String keyword1 = "test1";
String keyword2 = "test2";
String keyword3 = "test3";
mSearchHistory.addSearchHistory(keyword1);
mSearchHistory.addSearchHistory(keyword2);
mSearchHistory.addSearchHistory(keyword3);
// 获取搜索历史
List<String> 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<String> 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<String> 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<String> 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<String> history = mSearchHistory.getRecentSearches(10);
assertTrue("Search history should not exceed max count", history.size() <= maxCount);
}
/**
*
*/
@Test
public void testAddEmptyKeyword() {
// 清除现有历史记录
mSearchHistory.clearSearchHistory();
// 添加空关键词
mSearchHistory.addSearchHistory("");
List<String> history = mSearchHistory.getRecentSearches(10);
assertTrue("Empty keyword should not be added to history", history.isEmpty());
}
}

@ -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<SearchResult> results = mSearchManager.search("", SearchManager.SortBy.RELEVANCE, 10);
assertTrue("Search with empty keyword should return empty list", results.isEmpty());
}
/**
*
*/
@Test
public void testSearchSuggestions() {
// 测试空关键词建议
List<String> 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<String> 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("<font"));
assertTrue("Highlighted text should contain keyword", highlighted.contains(keyword));
// 测试空文本高亮
highlighted = mSearchManager.highlightKeyword(null, keyword);
assertNull("Highlighted text should be null for null input", highlighted);
// 测试空关键词高亮
highlighted = mSearchManager.highlightKeyword(text, null);
assertEquals("Highlighted text should be same as input for null keyword", text, highlighted);
}
/**
*
*/
@Test
public void testSortByEnum() {
assertEquals("RELEVANCE should be 0", 0, SearchManager.SortBy.RELEVANCE.ordinal());
assertEquals("CREATED_DATE should be 1", 1, SearchManager.SortBy.CREATED_DATE.ordinal());
assertEquals("MODIFIED_DATE should be 2", 2, SearchManager.SortBy.MODIFIED_DATE.ordinal());
}
}

@ -0,0 +1,137 @@
/*
* 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 org.junit.Before;
import org.junit.Test;
/**
* SearchResult
*/
public class SearchResultTest {
private SearchResult mSearchResult;
@Before
public void setUp() {
mSearchResult = new SearchResult();
}
/**
*
*/
@Test
public void testRelevanceScoreCalculation() {
// 设置测试数据
mSearchResult.setTitle("Test Title");
mSearchResult.setContent("This is a test content for testing relevance score calculation");
mSearchResult.setSnippet("Test snippet");
// 测试完全匹配标题
mSearchResult.calculateRelevanceScore("Test Title");
float score = mSearchResult.getRelevanceScore();
assertTrue("Exact title match should have high score", score > 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);
}
/**
* SearchResultgettersetter
*/
@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"));
}
}
Loading…
Cancel
Save