Changeset 0.17.3 (#78)

This commit is contained in:
2026-02-28 15:20:23 +00:00
parent a008dac488
commit 12e316c234
29 changed files with 3347 additions and 65 deletions

View File

@@ -9,6 +9,7 @@ import (
"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"
)
@@ -20,6 +21,7 @@ type createNoteRequest struct {
FolderPath string `json:"folder_path"`
Tags []string `json:"tags"`
SourceChannelID string `json:"source_channel_id"`
SourceMessageID string `json:"source_message_id"`
}
type updateNoteRequest struct {
@@ -38,6 +40,7 @@ type noteResponse struct {
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"`
}
@@ -85,6 +88,7 @@ func toNoteResponse(n *models.Note) noteResponse {
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"),
}
@@ -132,6 +136,10 @@ func (h *NoteHandler) Create(c *gin.Context) {
if req.SourceChannelID != "" {
sourceChannelID = &req.SourceChannelID
}
var sourceMessageID *string
if req.SourceMessageID != "" {
sourceMessageID = &req.SourceMessageID
}
note := &models.Note{
UserID: userID,
@@ -140,6 +148,7 @@ func (h *NoteHandler) Create(c *gin.Context) {
FolderPath: folder,
Tags: tags,
SourceChannelID: sourceChannelID,
SourceMessageID: sourceMessageID,
}
if err := h.stores.Notes.Create(c.Request.Context(), note); err != nil {
@@ -147,6 +156,14 @@ func (h *NoteHandler) Create(c *gin.Context) {
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))
}
@@ -235,6 +252,11 @@ func (h *NoteHandler) Update(c *gin.Context) {
return
}
// Re-extract links if content changed
if req.Content != nil {
h.extractAndStoreLinks(c, noteID, updated.Content, userID)
}
c.JSON(http.StatusOK, toNoteResponse(updated))
}
@@ -500,3 +522,122 @@ func normalizeFolderPath(p string) string {
}
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)
}