feat: v3 增强 - FTS5全文搜索+双向链接/知识图谱+自动保存草稿+标签管理+实时预览编辑器+PWA

- FTS5中文分词搜索(需-tags=sqlite_fts5)
- [[wiki链接]]反向链接+SVG知识图谱
- 自动保存草稿(不触发版本历史)
- 标签重命名/合并/删除+使用统计
- 后台编辑实时预览+格式工具栏
- PWA manifest+service worker+移动端适配
- 冒烟测试扩至66例全通过
This commit is contained in:
Your Name
2026-08-11 12:38:41 +08:00
parent cbfaac3d4f
commit b93c6f1e16
14 changed files with 1720 additions and 16 deletions
+260 -5
View File
@@ -1,10 +1,12 @@
package repository
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@@ -37,13 +39,156 @@ func NewNoteRepository(dbPath string) (*NoteRepository, error) {
return nil, fmt.Errorf("数据库迁移失败: %w", err)
}
return &NoteRepository{db: db}, nil
repo := &NoteRepository{db: db}
// 初始化 FTS5 全文搜索(含中文 trigram 分词)
if err := repo.initFTS5(); err != nil {
return nil, fmt.Errorf("初始化全文搜索失败: %w", err)
}
return repo, nil
}
// ─────────────── FTS5 全文搜索 ───────────────
// initFTS5 创建 FTS5 虚拟表并全量重建索引。
// 采用 unicode61 分词器 + 在 Go 层对中文做逐字分词(segmentCJK),
// 使中文搜索支持 1~2 个字符(trigram 只支持 3 字以上,不适合中文短词)。
func (r *NoteRepository) initFTS5() error {
// 独立 FTS5 表(不依赖外部内容表,由 Go 代码维护,便于中文分词)
createSQL := `
CREATE VIRTUAL TABLE IF NOT EXISTS note_search USING fts5(
title, content, category, tags,
tokenize='unicode61 remove_diacritics 2'
);`
if err := r.db.Exec(createSQL).Error; err != nil {
return err
}
// 启动时全量重建索引(低成本,保证索引与数据一致)
return r.rebuildAllFTS()
}
// segmentCJK 在中文/日文/韩文等连写字符之间插入空格,并在 CJK 与 ASCII 之间也插入空格,
// 使 unicode61 分词器能把每个汉字作为独立词元索引(支持短词),同时保留英文单词。
func segmentCJK(s string) string {
if s == "" {
return ""
}
isCJKChar := func(r rune) bool {
return (r >= 0x4E00 && r <= 0x9FFF) || // CJK 统一表意文字
(r >= 0x3040 && r <= 0x30FF) || // 日文平假名/片假名
(r >= 0xAC00 && r <= 0xD7AF) || // 韩文
(r >= 0x3400 && r <= 0x4DBF) // CJK 扩展 A
}
var b strings.Builder
first := true
var prevIsCJK bool
for _, r := range s {
cur := isCJKChar(r)
if !first && cur != prevIsCJK {
b.WriteRune(' ')
} else if !first && cur && prevIsCJK {
// 连续 CJK 字符之间也加空格,使每个字独立
b.WriteRune(' ')
}
b.WriteRune(r)
prevIsCJK = cur
first = false
}
return b.String()
}
// rebuildNoteFTS 重建某篇笔记的全文索引行(先删后插)
func (r *NoteRepository) rebuildNoteFTS(id uint) error {
var note model.Note
if err := r.db.Unscoped().First(&note, id).Error; err != nil {
// 笔记不存在则清除索引行
_ = r.db.Exec(`DELETE FROM note_search WHERE rowid = ?`, id).Error
return nil
}
sTitle := segmentCJK(note.Title)
sContent := segmentCJK(note.Content)
sCategory := segmentCJK(note.Category)
sTags := segmentCJK(note.Tags)
if err := r.db.Exec(`DELETE FROM note_search WHERE rowid = ?`, id).Error; err != nil {
return err
}
return r.db.Exec(`
INSERT INTO note_search(rowid, title, content, category, tags)
VALUES (?, ?, ?, ?, ?)`,
id, sTitle, sContent, sCategory, sTags).Error
}
// rebuildAllFTS 全量重建所有未删除笔记的索引(先清空再重建)
func (r *NoteRepository) rebuildAllFTS() error {
// 常规 FTS5 表用 DELETE 清空所有行
if err := r.db.Exec(`DELETE FROM note_search`).Error; err != nil {
return err
}
var ids []uint
if err := r.db.Model(&model.Note{}).Where("is_folder = ?", false).Pluck("id", &ids).Error; err != nil {
return err
}
for _, id := range ids {
if err := r.rebuildNoteFTS(id); err != nil {
return err
}
}
return nil
}
// FTS5Search 使用全文索引搜索(同时支持英文与中文短词)
// 返回匹配的笔记列表(排除已删除与目录)
func (r *NoteRepository) FTS5Search(keyword string, page, pageSize int) ([]model.NoteListItem, int64, error) {
// 查询词同样做中文逐字分词,与索引侧保持一致
keyword = strings.TrimSpace(segmentCJK(keyword))
if keyword == "" {
return nil, 0, nil
}
var total int64
// 先统计匹配总数(通过 FTS 表 join 到 notes 过滤目录与已删除)
countQuery := `
SELECT COUNT(*) FROM note_search s
JOIN notes n ON n.id = s.rowid
WHERE note_search MATCH ? AND n.is_folder = 0
AND (n.deleted_at IS NULL OR n.deleted_at = '')`
if err := r.db.Raw(countQuery, keyword).Scan(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
var items []model.NoteListItem
err := r.db.Raw(`
SELECT n.id, n.title, n.category, n.tags,
CASE WHEN n.password != '' THEN 1 ELSE 0 END AS has_password,
n.is_pinned, n.is_favorite, n.is_public, n.parent_id, n.is_folder,
n.sort_order, n.visit_count, n.created_at, n.updated_at
FROM note_search s
JOIN notes n ON n.id = s.rowid
WHERE note_search MATCH ? AND n.is_folder = 0
AND (n.deleted_at IS NULL OR n.deleted_at = '')
ORDER BY bm25(note_search), n.updated_at DESC
LIMIT ? OFFSET ?`,
keyword, pageSize, offset,
).Scan(&items).Error
if err != nil {
return nil, 0, err
}
return items, total, nil
}
// Create 创建笔记
func (r *NoteRepository) Create(note *model.Note) error {
res := r.db.Select("Title", "Content", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder", "ShareToken", "ShareExpireAt", "VisitCount").Create(note)
return res.Error
res := r.db.Select("Title", "Content", "DraftContent", "Category", "Tags", "Password", "IsPinned", "IsFavorite", "IsPublic", "ParentID", "IsFolder", "SortOrder", "ShareToken", "ShareExpireAt", "VisitCount").Create(note)
if res.Error != nil {
return res.Error
}
// 建立全文索引(目录不索引)
if !note.IsFolder {
return r.rebuildNoteFTS(note.ID)
}
return nil
}
// GetByID 根据 ID 获取笔记(排除已删除)
@@ -68,12 +213,35 @@ func (r *NoteRepository) GetByIDIncludingDeleted(id uint) (*model.Note, error) {
// Update 更新笔记
func (r *NoteRepository) Update(note *model.Note) error {
return r.db.Save(note).Error
if err := r.db.Save(note).Error; err != nil {
return err
}
// 更新全文索引(目录不索引)
if !note.IsFolder {
return r.rebuildNoteFTS(note.ID)
}
return nil
}
// UpdateFields 按字段更新(避免 Save 覆盖所有字段)
func (r *NoteRepository) UpdateFields(id uint, fields map[string]interface{}) error {
return r.db.Model(&model.Note{}).Where("id = ?", id).Updates(fields).Error
if err := r.db.Model(&model.Note{}).Where("id = ?", id).Updates(fields).Error; err != nil {
return err
}
// 若更新涉及可索引字段(标题/内容/分类/标签),同步重建索引
if _, ok := fields["Title"]; ok {
return r.rebuildNoteFTS(id)
}
if _, ok := fields["Content"]; ok {
return r.rebuildNoteFTS(id)
}
if _, ok := fields["Category"]; ok {
return r.rebuildNoteFTS(id)
}
if _, ok := fields["Tags"]; ok {
return r.rebuildNoteFTS(id)
}
return nil
}
// Delete 软删除笔记
@@ -331,6 +499,93 @@ func (r *NoteRepository) GetTags() ([]string, error) {
return result, nil
}
// ─────────────── 标签管理 ───────────────
// UpdateTagAll 将所有笔记中出现的指定标签重命名/合并/删除。
// oldTag 为要操作的旧标签;newTag 传入新名称实现重命名,传空字符串则删除该标签。
func (r *NoteRepository) UpdateTagAll(oldTag, newTag string) (int64, error) {
var notes []model.Note
if err := r.db.Where("tags LIKE ?", fmt.Sprintf("%%\"%s\"%%", oldTag)).
Where("is_folder = ?", false).
Find(&notes).Error; err != nil {
return 0, err
}
changed := int64(0)
for i := range notes {
var tagSlice []string
if json.Unmarshal([]byte(notes[i].Tags), &tagSlice) != nil {
continue
}
// 过滤掉旧标签;若 newTag 非空则加入新标签(避免重复)
seen := make(map[string]bool)
var newSlice []string
for _, t := range tagSlice {
if t == oldTag {
changed++
if newTag != "" && !seen[newTag] {
newSlice = append(newSlice, newTag)
seen[newTag] = true
}
continue
}
if !seen[t] {
newSlice = append(newSlice, t)
seen[t] = true
}
}
newJSON, _ := json.Marshal(newSlice)
if err := r.db.Model(&model.Note{}).Where("id = ?", notes[i].ID).
Update("tags", string(newJSON)).Error; err != nil {
return changed, err
}
// 标签变化,同步全文索引
_ = r.rebuildNoteFTS(notes[i].ID)
}
return changed, nil
}
// ─────────────── 双向链接 / 知识图谱 ───────────────
// GetAllNotesLight 获取所有未删除笔记的 id、标题 与 标签(用于解析 [[wiki链接]] 与标签统计)
func (r *NoteRepository) GetAllNotesLight() ([]model.NoteListItem, error) {
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("is_folder = ?", false).
Select("id, title, tags").Find(&items).Error
return items, err
}
// GetAllLinks 获取所有未删除笔记的 id、标题、内容(用于扫描双向链接与构建图谱)
// 仅返回轻量字段以降低内存占用
func (r *NoteRepository) GetAllContentLight() ([]struct {
ID uint `gorm:"column:id"`
Title string `gorm:"column:title"`
Content string `gorm:"column:content"`
}, error) {
var items []struct {
ID uint `gorm:"column:id"`
Title string `gorm:"column:title"`
Content string `gorm:"column:content"`
}
err := r.db.Model(&model.Note{}).
Where("is_folder = ?", false).
Select("id, title, content").Find(&items).Error
return items, err
}
// GetByIDs 批量获取笔记(用于解析链接指向的笔记是否存在)
func (r *NoteRepository) GetByIDs(ids []uint) ([]model.NoteListItem, error) {
if len(ids) == 0 {
return nil, nil
}
var items []model.NoteListItem
err := r.db.Model(&model.Note{}).
Where("id IN ?", ids).
Select("id, title").Find(&items).Error
return items, err
}
// ─────────────── 版本历史 ───────────────
// SaveVersion 保存笔记新版本快照