644 lines
17 KiB
Go
644 lines
17 KiB
Go
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)
|
|
}
|