Changeset 0.29.0 (#195)

This commit is contained in:
2026-03-17 16:28:47 +00:00
parent 128cbb8174
commit 5d637d3a90
129 changed files with 9418 additions and 3016 deletions

View File

@@ -7,7 +7,6 @@ import (
"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"
@@ -405,69 +404,30 @@ func (h *NoteHandler) Search(c *gin.Context) {
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})
// 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(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
}
results := make([]searchResult, 0, len(storeResults))
for _, sr := range storeResults {
tags := sr.Tags
if tags == nil {
tags = []string{}
}
r.Tags = tags
results = append(results, r)
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{
@@ -483,29 +443,19 @@ func (h *NoteHandler) searchPostgres(c *gin.Context, userID, q string, limit int
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)
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
}
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)
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})