This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/notes.go
2026-03-17 16:28:47 +00:00

603 lines
16 KiB
Go

package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/notelinks"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Request / Response Types ────────────────
type createNoteRequest struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
SourceChannelID string `json:"source_channel_id"`
SourceMessageID string `json:"source_message_id"`
}
type updateNoteRequest struct {
Title *string `json:"title"`
Content *string `json:"content"`
FolderPath *string `json:"folder_path"`
Tags []string `json:"tags"`
// Mode controls how content is applied: "replace" (default), "append", "prepend"
Mode string `json:"mode"`
}
type noteResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
SourceChannelID *string `json:"source_channel_id,omitempty"`
SourceMessageID *string `json:"source_message_id,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type noteListItem struct {
ID string `json:"id"`
Title string `json:"title"`
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
Preview string `json:"preview"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type searchResult struct {
noteListItem
Rank float64 `json:"rank"`
Headline string `json:"headline"`
}
// NoteHandler handles notes CRUD.
type NoteHandler struct {
stores store.Stores
}
// NewNoteHandler creates a new handler.
func NewNoteHandler(s ...store.Stores) *NoteHandler {
h := &NoteHandler{}
if len(s) > 0 {
h.stores = s[0]
}
return h
}
// toNoteResponse converts a models.Note to a noteResponse.
func toNoteResponse(n *models.Note) noteResponse {
tags := n.Tags
if tags == nil {
tags = []string{}
}
return noteResponse{
ID: n.ID,
Title: n.Title,
Content: n.Content,
FolderPath: n.FolderPath,
Tags: tags,
SourceChannelID: n.SourceChannelID,
SourceMessageID: n.SourceMessageID,
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func toNoteListItem(n models.Note) noteListItem {
tags := n.Tags
if tags == nil {
tags = []string{}
}
preview := n.Content
if len(preview) > 200 {
preview = preview[:200]
}
return noteListItem{
ID: n.ID,
Title: n.Title,
FolderPath: n.FolderPath,
Tags: tags,
Preview: preview,
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
// ── Create ──────────────────────────────────
// POST /api/v1/notes
func (h *NoteHandler) Create(c *gin.Context) {
var req createNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := getUserID(c)
folder := normalizeFolderPath(req.FolderPath)
tags := req.Tags
if tags == nil {
tags = []string{}
}
var sourceChannelID *string
if req.SourceChannelID != "" {
sourceChannelID = &req.SourceChannelID
}
var sourceMessageID *string
if req.SourceMessageID != "" {
sourceMessageID = &req.SourceMessageID
}
note := &models.Note{
UserID: userID,
Title: req.Title,
Content: req.Content,
FolderPath: folder,
Tags: tags,
SourceChannelID: sourceChannelID,
SourceMessageID: sourceMessageID,
}
if err := h.stores.Notes.Create(c.Request.Context(), note); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create note"})
return
}
// Extract and store wikilinks
h.extractAndStoreLinks(c, note.ID, note.Content, userID)
// Resolve dangling links from other notes that reference this title
if h.stores.NoteLinks != nil {
h.stores.NoteLinks.ResolveByTitle(c.Request.Context(), userID, note.ID, note.Title)
}
// Auto-associate note with source channel's project (v0.19.0)
if req.SourceChannelID != "" && h.stores.Projects != nil {
projID, _ := h.stores.Projects.GetProjectIDForChannel(
c.Request.Context(), req.SourceChannelID)
if projID != "" {
_ = h.stores.Projects.AddNote(c.Request.Context(), projID, note.ID)
}
}
c.JSON(http.StatusCreated, toNoteResponse(note))
}
// ── Get ─────────────────────────────────────
// GET /api/v1/notes/:id
func (h *NoteHandler) Get(c *gin.Context) {
userID := getUserID(c)
noteID := c.Param("id")
note, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
// Verify ownership
if note.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
c.JSON(http.StatusOK, toNoteResponse(note))
}
// ── Update ──────────────────────────────────
// PUT /api/v1/notes/:id
func (h *NoteHandler) Update(c *gin.Context) {
var req updateNoteRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := getUserID(c)
noteID := c.Param("id")
// Verify ownership
existing, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil || existing.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
// Build fields map
fields := map[string]interface{}{}
if req.Title != nil {
fields["title"] = *req.Title
}
if req.Content != nil {
mode := strings.ToLower(req.Mode)
switch mode {
case "append":
fields["content"] = existing.Content + *req.Content
case "prepend":
fields["content"] = *req.Content + existing.Content
default: // "replace" or empty
fields["content"] = *req.Content
}
}
if req.FolderPath != nil {
fields["folder_path"] = normalizeFolderPath(*req.FolderPath)
}
if req.Tags != nil {
fields["tags"] = req.Tags
}
if len(fields) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := h.stores.Notes.Update(c.Request.Context(), noteID, fields); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update note"})
return
}
// Re-fetch to get updated timestamps
updated, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch updated note"})
return
}
// Re-extract links if content changed
if req.Content != nil {
h.extractAndStoreLinks(c, noteID, updated.Content, userID)
}
c.JSON(http.StatusOK, toNoteResponse(updated))
}
// ── Delete ──────────────────────────────────
// DELETE /api/v1/notes/:id
func (h *NoteHandler) Delete(c *gin.Context) {
userID := getUserID(c)
noteID := c.Param("id")
// Verify ownership
existing, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil || existing.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
if err := h.stores.Notes.Delete(c.Request.Context(), noteID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete note"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// ── Bulk Delete ────────────────────────────
// POST /api/v1/notes/bulk-delete
func (h *NoteHandler) BulkDelete(c *gin.Context) {
var req struct {
IDs []string `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ids array is empty"})
return
}
if len(req.IDs) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "max 100 notes per request"})
return
}
userID := getUserID(c)
count, err := h.stores.Notes.BulkDelete(c.Request.Context(), req.IDs, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete notes"})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": count})
}
// GET /api/v1/notes?folder=/path&tag=sometag&limit=50&offset=0&sort=created_asc
func (h *NoteHandler) List(c *gin.Context) {
userID := getUserID(c)
folder := c.Query("folder")
tag := c.Query("tag")
sort := c.DefaultQuery("sort", "updated_desc")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if limit <= 0 || limit > 200 {
limit = 50
}
opts := store.NoteListOptions{
ListOptions: store.ListOptions{
Limit: limit,
Offset: offset,
},
FolderPath: normalizeFolderPath(folder),
Tag: tag,
}
if folder == "" {
opts.FolderPath = ""
}
// Map sort parameter
switch sort {
case "created_asc":
opts.Sort = "created_at"
opts.Order = "ASC"
case "created_desc":
opts.Sort = "created_at"
opts.Order = "DESC"
case "updated_asc":
opts.Sort = "updated_at"
opts.Order = "ASC"
case "title_asc":
opts.Sort = "title"
opts.Order = "ASC"
case "title_desc":
opts.Sort = "title"
opts.Order = "DESC"
default: // "updated_desc"
opts.Sort = ""
opts.Order = ""
}
notes, total, err := h.stores.Notes.ListForUser(c.Request.Context(), userID, opts)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"})
return
}
items := make([]noteListItem, 0, len(notes))
for _, n := range notes {
items = append(items, toNoteListItem(n))
}
c.JSON(http.StatusOK, gin.H{
"data": items,
"total": total,
"limit": limit,
"offset": offset,
})
}
// ── Search ──────────────────────────────────
// GET /api/v1/notes/search?q=query&limit=20
func (h *NoteHandler) Search(c *gin.Context) {
userID := getUserID(c)
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if limit <= 0 || limit > 100 {
limit = 20
}
// SearchKeyword handles dialect internally: PG uses ts_rank/ts_headline, SQLite uses LIKE.
storeResults, err := h.stores.Notes.SearchKeyword(c.Request.Context(), userID, q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
results := make([]searchResult, 0, len(storeResults))
for _, sr := range storeResults {
tags := sr.Tags
if tags == nil {
tags = []string{}
}
results = append(results, searchResult{
noteListItem: noteListItem{
ID: sr.ID,
Title: sr.Title,
FolderPath: sr.FolderPath,
Tags: tags,
Preview: sr.Excerpt,
},
Rank: sr.Rank,
Headline: sr.Headline,
})
}
c.JSON(http.StatusOK, gin.H{
"data": results,
"query": q,
"total": len(results),
})
}
// ── List Folders ────────────────────────────
// GET /api/v1/notes/folders
func (h *NoteHandler) ListFolders(c *gin.Context) {
userID := getUserID(c)
storeFolders, err := h.stores.Notes.ListFolders(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"})
return
}
type folderInfo struct {
Path string `json:"path"`
Count int `json:"count"`
}
folders := make([]folderInfo, 0, len(storeFolders))
for _, f := range storeFolders {
folders = append(folders, folderInfo{Path: f.Path, Count: f.Count})
}
c.JSON(http.StatusOK, gin.H{"folders": folders})
}
// ── Helpers ─────────────────────────────────
// normalizeFolderPath ensures consistent folder path format.
func normalizeFolderPath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if !strings.HasSuffix(p, "/") {
p = p + "/"
}
// Collapse double slashes
for strings.Contains(p, "//") {
p = strings.ReplaceAll(p, "//", "/")
}
return p
}
// ── Wikilink Endpoints ──────────────────────
// GET /api/v1/notes/search-titles?q=query&limit=10
func (h *NoteHandler) SearchTitles(c *gin.Context) {
userID := getUserID(c)
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
if limit <= 0 || limit > 50 {
limit = 10
}
notes, err := h.stores.Notes.SearchTitles(c.Request.Context(), userID, q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
return
}
type titleResult struct {
ID string `json:"id"`
Title string `json:"title"`
}
results := make([]titleResult, 0, len(notes))
for _, n := range notes {
results = append(results, titleResult{ID: n.ID, Title: n.Title})
}
c.JSON(http.StatusOK, gin.H{"data": results})
}
// GET /api/v1/notes/:id/backlinks
func (h *NoteHandler) Backlinks(c *gin.Context) {
userID := getUserID(c)
noteID := c.Param("id")
// Verify ownership
note, err := h.stores.Notes.GetByID(c.Request.Context(), noteID)
if err != nil || note.UserID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "note not found"})
return
}
if h.stores.NoteLinks == nil {
c.JSON(http.StatusOK, gin.H{"data": []interface{}{}})
return
}
results, err := h.stores.NoteLinks.Backlinks(c.Request.Context(), noteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load backlinks"})
return
}
if results == nil {
results = []models.NoteLinkResult{}
}
c.JSON(http.StatusOK, gin.H{"data": results})
}
// GET /api/v1/notes/graph
func (h *NoteHandler) Graph(c *gin.Context) {
userID := getUserID(c)
if h.stores.NoteLinks == nil {
c.JSON(http.StatusOK, models.NoteGraph{
Nodes: []models.NoteGraphNode{},
Edges: []models.NoteGraphEdge{},
Unresolved: []models.NoteGraphDangling{},
})
return
}
graph, err := h.stores.NoteLinks.Graph(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load graph"})
return
}
c.JSON(http.StatusOK, graph)
}
// ── Link Extraction Helper ──────────────────
// extractAndStoreLinks parses [[wikilinks]] from content and stores them.
func (h *NoteHandler) extractAndStoreLinks(c *gin.Context, noteID, content, userID string) {
if h.stores.NoteLinks == nil {
return
}
links := notelinks.ExtractWikilinks(content)
if len(links) == 0 {
// Clear any existing links
h.stores.NoteLinks.ReplaceLinks(c.Request.Context(), noteID, nil)
return
}
// Resolve titles to note IDs
ctx := c.Request.Context()
for i, link := range links {
notes, err := h.stores.Notes.SearchTitles(ctx, userID, link.TargetTitle, 1)
if err == nil {
for _, n := range notes {
if strings.EqualFold(n.Title, link.TargetTitle) {
id := n.ID
links[i].TargetNoteID = &id
break
}
}
}
}
h.stores.NoteLinks.ReplaceLinks(ctx, noteID, links)
}