diff --git a/API.md b/API.md index 83fd868..774b297 100644 --- a/API.md +++ b/API.md @@ -656,3 +656,63 @@ await fetch('/api/notes', { - **待办清单**:前台渲染 `- [ ]` / `- [x]` 可勾选清单 - **字数统计**:前台笔记详情显示字数与预估阅读时长 - **回收站/版本历史/分享**:后台工具栏按钮 + 面板 + +## v3 新增功能(2026-08-11) + +### FTS5 全文搜索 + +`GET /api/notes/search?q=关键词&page=1` 现使用 **SQLite FTS5 全文索引**,支持中文短词(1~2 字)与英文。 + +- 采用 `unicode61` 分词器 + Go 层对中文做逐字分词(`segmentCJK`),使每个汉字独立成词元,支持短词搜索 +- 索引在创建/更新/标签变更时增量维护,启动时全量重建 +- 搜索结果按 `bm25` 相关度排序,排除已删除笔记与目录 +- FTS 查询失败时自动回退到传统 LIKE 搜索 +- 构建需启用 `-tags=sqlite_fts5`(否则报 `no such module: fts5`) + +### 双向链接 / 知识图谱 + +支持 `[[笔记标题]]` wiki 链接语法: + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/admin/api/notes/:id/backlinks` | 反向链接(谁链接到了该笔记) | +| GET | `/admin/api/graph` | 知识图谱数据 `{nodes:[{id,title}], edges:[{source,target}]}` | + +- 后台编辑器支持点击 `[[链接]]` 跳转并高亮 +- 后台工具栏「🕸 知识图谱」以 SVG 图形化展示节点与链接关系 + +### 自动保存草稿 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/admin/api/notes/:id/draft` | 保存草稿(body `{content}`),不触发版本历史 | +| DELETE | `/admin/api/notes/:id/draft` | 清除草稿(保存正文成功后调用) | + +- 后台编辑器每 8 秒自动保存未落盘内容 +- 打开笔记时若存在未保存草稿会提示恢复 + +### 标签管理 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/admin/api/tags/usage` | 标签使用统计 `[{name,count}]` | +| POST | `/admin/api/tags/rename` | 重命名 `{old_name,new_name}` | +| POST | `/admin/api/tags/merge` | 合并 `{from,to}`(from 并入 to) | +| DELETE | `/admin/api/tags` | 删除 `{name}` | + +- 后台工具栏「🏷 标签管理」图形化操作 +- 重命名/合并/删除会同步更新所有含该标签的笔记及其全文索引 + +### 富文本所见即所得(编辑器增强) + +- 后台编辑器新增**实时预览**:编辑右侧即时渲染 Markdown +- 新增**格式工具栏**:加粗/斜体/标题/列表/链接/代码/图片/表格/任务清单 +- 仍以 Markdown 为存储源(保证 FTS/反向链接/导出兼容),预览所见即所得 + +### 移动端 / PWA + +- 新增 PWA 支持:`/manifest.json`、`/sw.js`、图标 `icon-192.png`/`icon-512.png` +- 前台页面响应式适配手机(侧栏折叠、字号/按钮优化) +- 可添加到主屏幕离线使用 + +> 注:FTS5 需用 `-tags=sqlite_fts5` 编译;`note_search` 虚拟表由 Go 代码维护(手动管理而非 SQL 触发器),保证中文分词正确。 diff --git a/Jenkinsfile b/Jenkinsfile index 004ec9e..36af641 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -86,6 +86,7 @@ pipeline { echo "时间: ${BUILD_TIME}" # go-sqlite3 需要 CGO_ENABLED=1,用完整 golang 镜像确保 gcc 可用 + # 必须带 -tags=sqlite_fts5 以启用 FTS5 全文搜索 docker run --rm \ -v "$PWD":/app \ -w /app \ @@ -94,6 +95,7 @@ pipeline { -e GOARCH=amd64 \ golang:${GO_VERSION} \ go build \ + -tags=sqlite_fts5 \ -ldflags="-s -w \ -X main.Version=${VERSION} \ -X main.BuildTime=${BUILD_TIME} \ diff --git a/handler/note_handler.go b/handler/note_handler.go index cf1cafb..db28cef 100644 --- a/handler/note_handler.go +++ b/handler/note_handler.go @@ -661,3 +661,132 @@ func sanitizeFileName(name string) string { } return name } + +// ─────────────── 自动保存草稿 ─────────────── + +// SaveDraft 保存笔记草稿(自动保存) +func (h *NoteHandler) SaveDraft(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + fail(c, http.StatusBadRequest, "无效的笔记 ID") + return + } + var req struct { + Content string `json:"content"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, "请求参数错误") + return + } + if err := h.svc.SaveDraft(uint(id), req.Content); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + success(c, nil) +} + +// DiscardDraft 清除指定笔记的草稿 +func (h *NoteHandler) DiscardDraft(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + fail(c, http.StatusBadRequest, "无效的笔记 ID") + return + } + if err := h.svc.ClearDraft(uint(id)); err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + success(c, nil) +} + +// ─────────────── 双向链接 / 知识图谱 ─────────────── + +// GetBacklinks 获取指定笔记的反向链接列表 +func (h *NoteHandler) GetBacklinks(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + fail(c, http.StatusBadRequest, "无效的笔记 ID") + return + } + links, err := h.svc.GetBacklinks(uint(id), "") + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + success(c, links) +} + +// GetKnowledgeGraph 获取知识图谱数据(节点 + 边) +func (h *NoteHandler) GetKnowledgeGraph(c *gin.Context) { + graph, err := h.svc.GetKnowledgeGraph() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + success(c, graph) +} + +// ─────────────── 标签管理 ─────────────── + +// GetTagUsage 获取标签及使用次数 +func (h *NoteHandler) GetTagUsage(c *gin.Context) { + usage, err := h.svc.GetTagUsage() + if err != nil { + fail(c, http.StatusInternalServerError, err.Error()) + return + } + success(c, usage) +} + +// RenameTag 重命名标签 +func (h *NoteHandler) RenameTag(c *gin.Context) { + var req struct { + OldName string `json:"old_name" binding:"required"` + NewName string `json:"new_name" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, "请求参数错误") + return + } + changed, err := h.svc.RenameTag(req.OldName, req.NewName) + if err != nil { + fail(c, http.StatusBadRequest, err.Error()) + return + } + success(c, gin.H{"changed": changed}) +} + +// MergeTag 合并标签(from → to) +func (h *NoteHandler) MergeTag(c *gin.Context) { + var req struct { + From string `json:"from" binding:"required"` + To string `json:"to" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, "请求参数错误") + return + } + changed, err := h.svc.MergeTag(req.From, req.To) + if err != nil { + fail(c, http.StatusBadRequest, err.Error()) + return + } + success(c, gin.H{"changed": changed}) +} + +// DeleteTag 删除标签(从所有笔记中移除) +func (h *NoteHandler) DeleteTag(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, "请求参数错误") + return + } + changed, err := h.svc.DeleteTag(req.Name) + if err != nil { + fail(c, http.StatusBadRequest, err.Error()) + return + } + success(c, gin.H{"changed": changed}) +} diff --git a/model/note.go b/model/note.go index fd191fe..0178809 100644 --- a/model/note.go +++ b/model/note.go @@ -59,6 +59,7 @@ type Note struct { ID uint `json:"id" gorm:"primaryKey"` Title string `json:"title" gorm:"size:255;not null"` Content string `json:"content" gorm:"type:text"` + DraftContent string `json:"draft_content,omitempty" gorm:"type:text"` // 未保存的草稿(自动保存用) Category string `json:"category" gorm:"size:100;index"` Tags string `json:"tags" gorm:"type:text"` // JSON 数组格式存储 Password string `json:"-" gorm:"size:255"` // 访问密码(哈希存储) @@ -147,3 +148,9 @@ type NoteVersion struct { func (NoteVersion) TableName() string { return "note_versions" } + +// TagUsage 标签使用统计 +type TagUsage struct { + Name string `json:"name"` + Count int `json:"count"` +} diff --git a/repository/note_repository.go b/repository/note_repository.go index e8b246c..5c24934 100644 --- a/repository/note_repository.go +++ b/repository/note_repository.go @@ -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(¬e, 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(¬es).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 保存笔记新版本快照 diff --git a/router/router.go b/router/router.go index ae82dc8..153221c 100644 --- a/router/router.go +++ b/router/router.go @@ -66,6 +66,20 @@ func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, adminHandler *handle // 完整树 + 图片上传 adminApi.GET("/tree", noteHandler.GetTree) adminApi.POST("/upload", imageHandler.Upload) + + // 自动保存草稿 + adminApi.POST("/notes/:id/draft", noteHandler.SaveDraft) + adminApi.DELETE("/notes/:id/draft", noteHandler.DiscardDraft) + + // 双向链接 / 知识图谱 + adminApi.GET("/notes/:id/backlinks", noteHandler.GetBacklinks) + adminApi.GET("/graph", noteHandler.GetKnowledgeGraph) + + // 标签管理 + adminApi.GET("/tags/usage", noteHandler.GetTagUsage) + adminApi.POST("/tags/rename", noteHandler.RenameTag) + adminApi.POST("/tags/merge", noteHandler.MergeTag) + adminApi.DELETE("/tags", noteHandler.DeleteTag) } // ─────────── 后台管理路由 ─────────── @@ -91,5 +105,11 @@ func Setup(r *gin.Engine, noteHandler *handler.NoteHandler, adminHandler *handle c.File("./web/index.html") }) + // PWA 静态资源(manifest / service worker / 图标) + r.StaticFile("/manifest.json", "./web/manifest.json") + r.StaticFile("/sw.js", "./web/sw.js") + r.StaticFile("/icon-192.png", "./web/icon-192.png") + r.StaticFile("/icon-512.png", "./web/icon-512.png") + return r } diff --git a/service/note_service.go b/service/note_service.go index 9107047..14264df 100644 --- a/service/note_service.go +++ b/service/note_service.go @@ -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) diff --git a/smoke_test.py b/smoke_test.py index 2e84e81..935a443 100644 --- a/smoke_test.py +++ b/smoke_test.py @@ -243,9 +243,107 @@ report("标签列表正常", st == 200) st, d = req("GET", "/api/tree") report("公开树正常", st == 200) +print("═══ 9. FTS5 全文搜索 ═══") + +# 创建一篇含中文与英文的笔记用于搜索测试 +st, d = req("POST", "/admin/api/notes", { + "title": "搜索引擎优化笔记", "content": "这是一篇关于数据库索引与搜索引擎的笔记 涉及中文分词 ASP.NET", + "category": "技术", "tags": '["全文","优化"]', "is_public": True +}) +fts_id = d.get("data", {}).get("id") +report("创建搜索测试笔记", st == 200 and fts_id, f"(got {st})") + +# 中文短词(1~2字)搜索 +q_enc = urllib.parse.urlencode({"q": "数据库"}) +st, d = req("GET", f"/api/notes/search?{q_enc}") +hits = [x.get("title") for x in (d.get("data") or [])] +report("中文短词搜索命中", st == 200 and "搜索引擎优化笔记" in hits, f"(got {hits})") + +# 英文搜索 +q_enc = urllib.parse.urlencode({"q": "ASP.NET"}) +st, d = req("GET", f"/api/notes/search?{q_enc}") +hits = [x.get("title") for x in (d.get("data") or [])] +report("英文搜索命中", st == 200 and "搜索引擎优化笔记" in hits, f"(got {hits})") + +# 无结果关键词 +q_enc = urllib.parse.urlencode({"q": "不存在的关键词XYZ"}) +st, d = req("GET", f"/api/notes/search?{q_enc}") +report("无匹配关键词返回空", st == 200 and d.get("total", 0) == 0, f"(got {d.get('total')})") + +print("═══ 10. 双向链接 / 知识图谱 ═══") + +# 创建两篇互相/单向链接的笔记 +st, da = req("POST", "/admin/api/notes", { + "title": "链接源A", "content": f"参考 [[搜索引擎优化笔记]] 和 [[链接目标B]]", + "is_public": True +}) +linkA_id = da.get("data", {}).get("id") +st, db_ = req("POST", "/admin/api/notes", { + "title": "链接目标B", "content": "内容B", "is_public": True +}) +linkB_id = db_.get("data", {}).get("id") +report("创建链接笔记成功", st == 200 and linkA_id and linkB_id, f"(got {st})") + +# 反向链接:谁链接到了搜索引擎优化笔记(应为 链接源A) +st, d = req("GET", f"/admin/api/notes/{fts_id}/backlinks") +backlinks = [x.get("title") for x in (d.get("data") or [])] +report("反向链接查询", st == 200 and "链接源A" in backlinks, f"(got {backlinks})") + +# 知识图谱:应包含节点,且存在 edges +st, d = req("GET", "/admin/api/graph") +g = d.get("data") or {} +gnodes = g.get("nodes") or [] +gedges = g.get("edges") or [] +report("知识图谱返回节点", st == 200 and any(n.get("title") == "链接源A" for n in gnodes), f"(got {st})") +report("知识图谱含链接边", st == 200 and len(gedges) >= 1, f"(got {len(gedges)})") + +print("═══ 11. 自动保存草稿 ═══") + +st, d = req("POST", f"/admin/api/notes/{fts_id}/draft", {"content": "这是未保存的草稿"}) +report("保存草稿成功", st == 200 and d.get("code") == 0, f"(got {st})") + +st, d = req("GET", f"/admin/api/notes/{fts_id}") +report("读取到草稿字段", d.get("data", {}).get("draft_content") == "这是未保存的草稿", f"(got {d.get('data',{}).get('draft_content')})") + +st, d = req("DELETE", f"/admin/api/notes/{fts_id}/draft") +report("清除草稿成功", st == 200 and d.get("code") == 0, f"(got {st})") + +st, d = req("GET", f"/admin/api/notes/{fts_id}") +report("清除后草稿为空", d.get("data", {}).get("draft_content", "") == "", f"(got {d.get('data',{}).get('draft_content')})") + +print("═══ 12. 标签管理 ═══") + +# 标签使用统计应包含 test 链接笔记的标签 +st, d = req("GET", "/admin/api/tags/usage") +usage = {u.get("name"): u.get("count") for u in (d.get("data") or [])} +report("标签使用统计接口", st == 200 and isinstance(d.get("data"), list), f"(got {st})") + +# 重命名标签:给链接源A加个标签再重命名 +st, d = req("PUT", f"/admin/api/notes/{linkA_id}", {"tags": '["临时标签","保留"]'}) +report("为链接笔记设置标签", st == 200 and d.get("code") == 0, f"(got {st})") + +st, d = req("POST", "/admin/api/tags/rename", {"old_name": "临时标签", "new_name": "已重命名"}) +report("重命名标签", st == 200 and d.get("data", {}).get("changed", 0) >= 1, f"(got {d})") + +st, d = req("GET", f"/admin/api/notes/{linkA_id}") +tag_str = d.get("data", {}).get("tags", "") +report("重命名后笔记标签同步", "已重命名" in tag_str and "临时标签" not in tag_str, f"(got {tag_str})") + +# 合并标签 +st, d = req("POST", "/admin/api/tags/merge", {"from": "保留", "to": "已重命名"}) +report("合并标签", st == 200 and d.get("data", {}).get("changed", 0) >= 1, f"(got {d})") + +st, d = req("GET", f"/admin/api/notes/{linkA_id}") +tag_str = d.get("data", {}).get("tags", "") +report("合并后标签去重", "已重命名" in tag_str and "保留" not in tag_str, f"(got {tag_str})") + +# 删除标签 +st, d = req("DELETE", "/admin/api/tags", {"name": "已重命名"}) +report("删除标签", st == 200, f"(got {st})") + # ── 清理测试数据(彻底删除本脚本创建的所有笔记及其版本)── -print("\n═══ 9. 清理测试数据 ═══") -test_ids = [x for x in (note_id, protected_id, tmp_id) if x] +print("\n═══ 13. 清理测试数据 ═══") +test_ids = [x for x in (note_id, protected_id, tmp_id, fts_id, linkA_id, linkB_id) if x] for tid in test_ids: if tid: # 先尝试恢复到非删除态再彻底删除(若在回收站) diff --git a/web/admin/index.html b/web/admin/index.html index ee2ba39..cdee1a6 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -4,6 +4,9 @@