package handlers import ( "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/database" "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) } 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 } // Use Postgres full-text search when available, LIKE fallback on SQLite if database.IsPostgres() { h.searchPostgres(c, userID, q, limit) return } // SQLite: use store's LIKE-based search notes, _, err := h.stores.Notes.Search(c.Request.Context(), userID, q, store.ListOptions{Limit: limit}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) return } results := make([]searchResult, 0, len(notes)) for _, n := range notes { results = append(results, searchResult{ noteListItem: toNoteListItem(n), Rank: 1.0, Headline: "", }) } c.JSON(http.StatusOK, gin.H{ "data": results, "query": q, "total": len(results), }) } // searchPostgres uses Postgres full-text search with ts_rank and ts_headline. func (h *NoteHandler) searchPostgres(c *gin.Context, userID, q string, limit int) { rows, err := database.DB.Query(` SELECT id, title, folder_path, tags, LEFT(content, 200), created_at::text, updated_at::text, ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank, ts_headline('english', content, plainto_tsquery('english', $2), 'MaxWords=40, MinWords=20, StartSel=**, StopSel=**') AS headline FROM notes WHERE user_id = $1 AND search_vector @@ plainto_tsquery('english', $2) ORDER BY rank DESC LIMIT $3 `, userID, q, limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"}) return } defer rows.Close() // Import pq at call site to avoid pulling it in for SQLite builds results := make([]searchResult, 0) for rows.Next() { var r searchResult var tags []string if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, pgScanStringArray(&tags), &r.Preview, &r.CreatedAt, &r.UpdatedAt, &r.Rank, &r.Headline); err != nil { continue } if tags == nil { tags = []string{} } r.Tags = tags results = append(results, r) } 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) rows, err := database.DB.Query(database.Q(` SELECT DISTINCT folder_path, COUNT(*) AS count FROM notes WHERE user_id = $1 GROUP BY folder_path ORDER BY folder_path `), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list folders"}) return } defer rows.Close() type folderInfo struct { Path string `json:"path"` Count int `json:"count"` } folders := make([]folderInfo, 0) for rows.Next() { var f folderInfo if err := rows.Scan(&f.Path, &f.Count); err != nil { continue } folders = append(folders, f) } 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) }