在前面的文章中我们已经实现了笔记和笔记本的创建、编辑、删除功能,今天我们来实现笔记搜索功能:当用户在搜索框中输入文字时,笔记列表会实时的显示搜索结果,匹配规则为笔记的标题或者正文中包含关键字(词)
如果笔记的正文中包含关键词,那么选中该笔记时将高亮关键词
支持全局搜索,能够从所有的笔记本中搜索关键词
最终效果如下:笔记搜索功能演示https://www.zhihu.com/video/1241794511703580672
使用Whoosh实现全文搜索
Whoosh是一个使用Python实现的全文搜索引擎库,它使用简便、快速可靠,详细介绍参考Introduction to Whooshwhoosh.readthedocs.io
首先安装Whoosh:
pip install Whoosh
在根目录下新建一个文件夹 services,然后新建 note_search_service.py 文件。
创建索引对象
在创建索引之前,需要指定索引目录,编辑 config.ini 文件,添加 index_dir:
[app]
data_dir = data
note_dir = notes
index_dir = data/indexes
# ....
编辑 note_search_service.py 文件,在初始化时如果索引不存在,则根据模式创建索引,否则打开索引对象。
import whoosh.index
from config import CONFIG
from whoosh.fields import NUMERIC, TEXT, Schema
from jieba.analyse import ChineseAnalyzer
class NoteSearchService:
def __init__(self):
if whoosh.index.exists_in(CONFIG['app']['index_dir']):
self.index = whoosh.index.open_dir(CONFIG['app']['index_dir'])
else:
schema = Schema(note_id=NUMERIC(stored=True, unique=True),
notebook_id=NUMERIC(stored=True),
title=TEXT(analyzer=ChineseAnalyzer()),
snippet=TEXT(analyzer=ChineseAnalyzer()))
self.index = whoosh.index.create_in(CONFIG['app']['index_dir'], schema)
这里创建了一个模式对象,里面有如下字段:note_id为数字类型的字段,stored=True 表示此字段会储存在索引文件中,unique=True 表示唯一性,这样就可以根据 note_id 来删除或者更新索引
notebook_id 和 note_id 类似,只是不具备唯一性
title 为文本类型的字段,也就是笔记标题索引。注意 analyzer 后面使用了结巴分词提供的中文分词器(whoosh 默认不支持中文分词)
snippet 为笔记纯文本,和 title 字段类似
初始化之后,我们就可以拿到索引对象。
上面提到了结巴分词,我们需要安装一下:
pip install jieba
添加索引内容
如果我们新建了一条笔记,则需要给这条笔记添加索引。
def add_doc(self, note):
with self.index.writer() as writer:
writer.add_document(**self._get_fields(note))
@staticmethod
def _get_fields(note):
return {'note_id': note.id, 'notebook_id': note.notebook_id, 'title': note.title, 'snippet': note.snippet}
索引对象的writer方法会返回一个用来操作索引文件的对象,调用 add_document 方法即可添加索引内容。
因为后面的一些方法也会进行类似的操作,所以定义了一个 _get_fields 方法来简化代码。
删除索引内容
如果笔记删除了,那么它对应的索引内容也需要删除。
def delete_doc(self, note):
self.index.delete_by_term('note_id', note.id)
因为在前面的模式对象中,note_id 字段设置成了唯一性,所以就可以根据 note_id 来删除对应的索引记录。
更新索引内容
如果笔记标题或者正文内容有所变化,其对应的索引内容也需要作相应的更新。
def update_doc(self, note):
with self.index.writer() as writer:
writer.update_document(**self._get_fields(note))
查找索引内容
如果笔记标题或者正文包含关键词,并且满足笔记本约束的话(属于当前笔记本或者全局范围内),说明此笔记成功匹配关键词。
# ...
from whoosh.qparser import MultifieldParser
from whoosh import query
# ...
def search(self, keyword, notebook_id=None):
with self.index.searcher() as searcher:
query_parser = MultifieldParser(["title", "snippet"], schema=self.index.schema).parse(keyword)
notebook_filter = query.Term("notebook_id", notebook_id) if notebook_id else None
results = searcher.search(query_parser, filter=notebook_filter, limit=None)
return [res['note_id'] for res in results]
Whoosh提供了一个多字段查询解析器 MultifieldParser,用在这里非常合适。
因为支持全局搜索或当前笔记本内搜索,所以我们需要配置过滤器参数,当 notebook_id 为空时表示全局搜索,否则在当前笔记本中搜索。
同步更新索引
当笔记增删改时,其索引内容也需要更新。编辑 views / list_panel.py 文件:
from services.note_search_service import NoteSearchService
def __init__(self, parent):
# ...
self.searcher = NoteSearchService()
pub.subscribe(self._on_note_updated, 'note.updated')
# ...
def _on_note_created(self, note):
# ...
self.searcher.add_doc(note)
def _on_note_updated(self, note):
self.searcher.update_doc(note)
def _on_note_deleting(self, note):
self.searcher.delete_doc(note)
# ...
实现搜索逻辑
编辑 views / header_panel.py:
def __init__(self, parent):
# ...
self._global_search_menu_id = wx.NewIdRef()
self.is_global_search = False
def _init_event(self):
# ...
self.search_bar.Bind(wx.EVT_TEXT, self._on_searching)
self.Bind(wx.EVT_MENU, self._on_global_search_menu_checked, id=self._global_search_menu_id)
def _on_searching(self, e):
pub.sendMessage('note.searching', keyword=self.keyword, is_global_search=self.is_global_search)
def _on_global_search_menu_checked(self, e):
self.is_global_search = e.IsChecked()
self._build_search_bar_menu()
if self.keyword:
pub.sendMessage('note.searching', keyword=self.keyword, is_global_search=self.is_global_search)
def _build_search_bar_menu(self):
menu = wx.Menu()
menu.AppendCheckItem(self._global_search_menu_id, '搜索所有笔记本').Check(self.is_global_search)
self.search_bar.SetMenu(menu)
if self.is_global_search:
self.search_bar.SetHint('搜索所有笔记本')
else:
self.search_bar.SetHint('搜索当前笔记本')
def set_count(self, count):
self.note_count = count
if self.keyword:
self.st_note_count.SetLabel(f'找到{self.note_count}条笔记')
else:
self.st_note_count.SetLabel(f'{self.note_count}条笔记')
def reset_search_bar(self):
self.search_bar.ChangeValue('')
@property
def keyword(self):
return self.search_bar.GetValue().strip()
全局搜索菜单依附于搜索框,每当勾选全局搜索选项时,就需要重新生成一个新菜单,否则勾选无效(这可能是 wx.SearchCtrl 的一个bug,有待确认)。
当搜索框的关键词变化或者全局菜单勾选时,就会发送 note.searching 消息。
编辑 views / list_panel.py,订阅 note.searching:
def __init__(self, parent):
# ...
self._notebook = None
pub.subscribe(self._on_note_searching, 'note.searching')
def _on_note_searching(self, keyword, is_global_search):
if keyword:
if is_global_search or not self._notebook:
notebook_id = None
else:
notebook_id = self._notebook.id
note_ids = self.searcher.search(keyword, notebook_id=notebook_id)
if note_ids:
notes = list(Note.select().where(Note.id.in_(note_ids)).order_by(self.header_panel.sort_option))
else:
notes = []
self._load(notes)
self.header_panel.set_count(len(notes))
else:
if self._notebook:
self._on_notebook_selected(self._notebook)
else:
self._on_root_selected()
def _on_notebook_selected(self, notebook):
# ...
self._notebook = notebook
self.header_panel.reset_search_bar()
def _on_root_selected(self):
# ...
self._notebook = None
self.header_panel.reset_search_bar()
这里保存了当前笔记本对象,是因为当搜索关键词清空后,需要重新加载当前笔记列表。
实现QuillJS查找功能
以上就实现了全文搜索功能,美中不足的是选中笔记时并没有高亮关键词。文本编辑器关键词高亮可以借助Quilljs提供的API实现。生成一个格式化文本节点,用于表示高亮文字,在Quilljs中叫做Blot
获取关键词在文本中所有的位置,生成一个索引数组。例如文本:abcabcd99ab,关键词为 ab,则索引数组为:[0, 3, 9]
有了索引和新建的Blot,就可以使用quill.format高亮关键词了
在 views / text_editor 目录下新建一个 searcher.js文件。
const Inline = Quill.import('blots/inline');
class MarkerBlot extends Inline {}
MarkerBlot.blotName = 'Marker';
MarkerBlot.className = 'marked';
MarkerBlot.tagName = 'div';
Quill.register(MarkerBlot);
class Searcher {
constructor(quill) {
this.quill = quill;
}
findAll(keyword) {
this.removeMark();
let keywordLength = keyword.length;
this.getIndexesOf(keyword).forEach(
index => this.quill.formatText(index, keywordLength, "Marker", true)
)
}
removeMark() {
this.quill.formatText(0, this.quill.getText().length, 'Marker', false);
}
getIndexesOf(keyword) {
if (keyword == null || keyword === "") {
return [];
}
keyword = keyword.toLowerCase();
let text = this.quill.getText().toLowerCase();
let keywordLength = keyword.length;
let startIndex = 0, curIndex, indexList = [];
while ((curIndex = text.indexOf(keyword, startIndex)) > -1) {
indexList.push(curIndex);
startIndex = curIndex + keywordLength;
}
return indexList;
}
}
编辑 quill.snow.css 文件,追加高亮效果:
.marked {
background-color: #fefb39;
display: inline;
}
编辑 index.html,引入 searcher.js:
编辑 core.js,添加搜索关键词方法:
let searcher = new Searcher(quill);
quill.findAll = function(keyword) {
searcher.findAll(keyword);
};
笔记高亮
编辑器层面的高亮效果已经实现了,该怎么整合到 TextEditor 组件中呢?我们知道,笔记加载实际上调用了 load_note 方法,更进一步来看,是订阅了 note.selected 消息,所以只需要在该消息中添加关键词数据即可。
修改 views / list_panel.py,给 _load 方法添加 keyword 参数:
def _load(self, notes, preserve_select=False, keyword=None):
if len(notes):
self.note_list_panel.replace(notes, preserve_select, keyword)
else:
self.note_list_panel.clear()
self._note_ids = list(map(lambda note: note.id, notes))
修改 views / note_list_panel.py:
def __init__(self, parent):
# ...
self.keyword = None
def replace(self, notes, preserve_select=False, keyword=None):
# ...
self.keyword = keyword
def clear(self):
# ...
self.keyword = None
def select(self, note):
# ...
pub.sendMessage('note.selected', note=self.selected_note, keyword=self.keyword)
修改 views / text_editor.py:
总结
如果将搜索相关逻辑转移到主窗体 MainFrame 中,会更加容易实现,因为MainFrame是最顶层组件,可以很容易获取到子组件的相关数据,有兴趣可以动手实践一下。