feat: v3 增强 - FTS5全文搜索+双向链接/知识图谱+自动保存草稿+标签管理+实时预览编辑器+PWA
- FTS5中文分词搜索(需-tags=sqlite_fts5) - [[wiki链接]]反向链接+SVG知识图谱 - 自动保存草稿(不触发版本历史) - 标签重命名/合并/删除+使用统计 - 后台编辑实时预览+格式工具栏 - PWA manifest+service worker+移动端适配 - 冒烟测试扩至66例全通过
This commit is contained in:
+209
-3
@@ -3,9 +3,12 @@ package service
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -206,7 +209,7 @@ func (s *NoteService) GetByParentID(parentID uint) ([]model.NoteListItem, error)
|
||||
return s.repo.GetByParentID(parentID)
|
||||
}
|
||||
|
||||
// SearchNotes 搜索笔记
|
||||
// SearchNotes 搜索笔记(优先使用 FTS5 全文索引,含中文分词)
|
||||
func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model.NoteListItem, int64, int, error) {
|
||||
if keyword == "" {
|
||||
return nil, 0, 0, errors.New("搜索关键词不能为空")
|
||||
@@ -214,10 +217,23 @@ func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model
|
||||
|
||||
page := parseInt(pageStr, 1)
|
||||
pageSize := parseInt(pageSizeStr, s.pageSize)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = s.pageSize
|
||||
}
|
||||
|
||||
items, total, err := s.repo.Search(keyword, page, pageSize)
|
||||
// FTS5 的 MATCH 语法:对用户输入做基本转义,避免语法错误
|
||||
keyword = sanitizeFTS5(keyword)
|
||||
|
||||
items, total, err := s.repo.FTS5Search(keyword, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, 0, 0, fmt.Errorf("搜索笔记失败: %w", err)
|
||||
// FTS5 失败时回退到传统 LIKE 搜索
|
||||
items, total, err = s.repo.Search(keyword, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, 0, 0, fmt.Errorf("搜索笔记失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
@@ -228,6 +244,21 @@ func (s *NoteService) SearchNotes(keyword, pageStr, pageSizeStr string) ([]model
|
||||
return items, total, totalPages, nil
|
||||
}
|
||||
|
||||
// sanitizeFTS5 对 FTS5 查询做安全转义,处理用户输入中的特殊字符(引号、操作符等)
|
||||
// 简单方案:去掉可能破坏 MATCH 语法的字符,将内容包裹为短语。
|
||||
func sanitizeFTS5(q string) string {
|
||||
// 移除 FTS5 特殊语法字符
|
||||
var b strings.Builder
|
||||
skip := map[rune]bool{'"': true, '^': true, '*': true, '(': true, ')': true, '{': true, '}': true, '[': true, ']': true, ':': true, '+': true, '-': true, '~': true}
|
||||
for _, r := range q {
|
||||
if skip[r] {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// GetCategories 获取所有分类
|
||||
func (s *NoteService) GetCategories() ([]string, error) {
|
||||
return s.repo.GetCategories()
|
||||
@@ -380,6 +411,181 @@ func (s *NoteService) UpgradePasswordHash(id uint, password string) error {
|
||||
return s.repo.Update(note)
|
||||
}
|
||||
|
||||
// ─────────────── 自动保存草稿 ───────────────
|
||||
|
||||
// SaveDraft 保存笔记草稿(仅更新草稿字段,不触发版本历史)
|
||||
// 返回是否有未保存草稿被记录
|
||||
func (s *NoteService) SaveDraft(id uint, content string) error {
|
||||
note, err := s.repo.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("笔记不存在")
|
||||
}
|
||||
if note.IsFolder {
|
||||
return errors.New("目录不支持草稿")
|
||||
}
|
||||
note.DraftContent = content
|
||||
// 直接更新草稿字段,保持 updated_at 不变(避免与正文保存混淆)
|
||||
return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": content})
|
||||
}
|
||||
|
||||
// ClearDraft 清除笔记草稿(保存正文成功后调用)
|
||||
func (s *NoteService) ClearDraft(id uint) error {
|
||||
return s.repo.UpdateFields(id, map[string]interface{}{"draft_content": ""})
|
||||
}
|
||||
|
||||
// ─────────────── 双向链接 / 知识图谱 ───────────────
|
||||
|
||||
// wikiLinkRe 匹配笔记正文中的 [[wiki链接]] 语法
|
||||
var wikiLinkRe = regexp.MustCompile(`\[\[([^\[\]|]+)(?:\|[^\[\]]*)?\]\]`)
|
||||
|
||||
// GetBacklinks 获取指向指定笔记的所有笔记(反向链接)
|
||||
func (s *NoteService) GetBacklinks(noteID uint, title string) ([]model.NoteListItem, error) {
|
||||
if title == "" {
|
||||
// 若未提供标题,先查一下
|
||||
n, err := s.repo.GetByID(noteID)
|
||||
if err != nil {
|
||||
return nil, errors.New("笔记不存在")
|
||||
}
|
||||
title = n.Title
|
||||
}
|
||||
|
||||
all, err := s.repo.GetAllNotesLight()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 找出所有包含 [[title]] 链接的笔记 ID
|
||||
var result []model.NoteListItem
|
||||
for _, n := range all {
|
||||
if n.ID == noteID {
|
||||
continue
|
||||
}
|
||||
full, err := s.repo.GetByID(n.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if wikiLinkRe.MatchString(full.Content) && strings.Contains(full.Content, "[["+title+"]]") {
|
||||
result = append(result, n)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GraphNode 知识图谱节点
|
||||
type GraphNode struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// GraphEdge 知识图谱边
|
||||
type GraphEdge struct {
|
||||
Source uint `json:"source"`
|
||||
Target uint `json:"target"`
|
||||
}
|
||||
|
||||
// GetKnowledgeGraph 构建完整知识图谱(节点 + [[链接]] 边)
|
||||
func (s *NoteService) GetKnowledgeGraph() (map[string]interface{}, error) {
|
||||
all, err := s.repo.GetAllContentLight()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 标题 → ID 映射,用于把 [[标题]] 解析为具体笔记
|
||||
titleToID := make(map[string]uint)
|
||||
nodes := make([]GraphNode, 0, len(all))
|
||||
for _, n := range all {
|
||||
titleToID[n.Title] = n.ID
|
||||
nodes = append(nodes, GraphNode{ID: n.ID, Title: n.Title})
|
||||
}
|
||||
|
||||
// 构建边(去重)
|
||||
type edgeKey struct{ src, dst uint }
|
||||
seen := make(map[edgeKey]bool)
|
||||
var edges []GraphEdge
|
||||
for _, n := range all {
|
||||
// 提取当前笔记中的所有 [[链接]]
|
||||
matches := wikiLinkRe.FindAllStringSubmatch(n.Content, -1)
|
||||
for _, m := range matches {
|
||||
// m[1] 是链接目标(标题或路径,取第一个 [[..]] 内容作为标题)
|
||||
link := strings.TrimSpace(m[1])
|
||||
if targetID, ok := titleToID[link]; ok && targetID != n.ID {
|
||||
key := edgeKey{n.ID, targetID}
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
edges = append(edges, GraphEdge{Source: n.ID, Target: targetID})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ─────────────── 标签管理 ───────────────
|
||||
|
||||
// RenameTag 重命名标签(所有含该标签的笔记同步更新)
|
||||
func (s *NoteService) RenameTag(oldTag, newTag string) (int64, error) {
|
||||
if oldTag == "" || newTag == "" {
|
||||
return 0, errors.New("标签名不能为空")
|
||||
}
|
||||
if oldTag == newTag {
|
||||
return 0, nil
|
||||
}
|
||||
return s.repo.UpdateTagAll(oldTag, newTag)
|
||||
}
|
||||
|
||||
// MergeTag 将 from 标签合并到 to 标签(from 消失)
|
||||
func (s *NoteService) MergeTag(from, to string) (int64, error) {
|
||||
if from == "" || to == "" {
|
||||
return 0, errors.New("标签名不能为空")
|
||||
}
|
||||
if from == to {
|
||||
return 0, nil
|
||||
}
|
||||
return s.repo.UpdateTagAll(from, to)
|
||||
}
|
||||
|
||||
// DeleteTag 删除指定标签(从所有笔记中移除)
|
||||
func (s *NoteService) DeleteTag(tag string) (int64, error) {
|
||||
if tag == "" {
|
||||
return 0, errors.New("标签名不能为空")
|
||||
}
|
||||
return s.repo.UpdateTagAll(tag, "")
|
||||
}
|
||||
|
||||
// GetTagUsage 获取每个标签及其使用次数
|
||||
func (s *NoteService) GetTagUsage() ([]model.TagUsage, error) {
|
||||
var result []model.TagUsage
|
||||
counts := make(map[string]int)
|
||||
all, err := s.repo.GetAllNotesLight()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, n := range all {
|
||||
var tags []string
|
||||
if json.Unmarshal([]byte(n.Tags), &tags) == nil {
|
||||
for _, t := range tags {
|
||||
counts[t]++
|
||||
}
|
||||
}
|
||||
}
|
||||
for tag, count := range counts {
|
||||
result = append(result, model.TagUsage{Name: tag, Count: count})
|
||||
}
|
||||
// 按使用次数降序
|
||||
for i := range result {
|
||||
for j := i + 1; j < len(result); j++ {
|
||||
if result[j].Count > result[i].Count {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// randomToken 生成安全的随机令牌
|
||||
func randomToken(bytesLen int) string {
|
||||
b := make([]byte, bytesLen)
|
||||
|
||||
Reference in New Issue
Block a user