KAOS/code/internal/server/search.go
djuka a3fc9b3af0 T13: Dodat search bar sa instant pretragom taskova i dokumenata
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:47:39 +00:00

192 lines
4.7 KiB
Go

package server
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/dal/kaos/internal/supervisor"
)
// searchResult represents a single search result.
type searchResult struct {
Type string // "task", "doc", "report"
Icon string // emoji icon
Title string // display title
Link string // URL to navigate to
Snippet string // context around the match
Status string // task status (only for tasks)
}
// searchResultsData holds data for the search results template.
type searchResultsData struct {
Query string
Results []searchResult
}
// handleSearch serves search results as an HTML fragment.
func (s *Server) handleSearch(c *gin.Context) {
query := strings.TrimSpace(c.Query("q"))
if query == "" {
c.String(http.StatusOK, "")
return
}
root := s.projectRoot()
var results []searchResult
// 1. Search tasks
tasks, _ := supervisor.ScanTasks(s.Config.TasksDir)
for _, t := range tasks {
if matchTask(t, query) {
snippet := taskSnippet(t, query)
results = append(results, searchResult{
Type: "task",
Icon: "📋",
Title: t.ID + ": " + t.Title,
Link: "/task/" + t.ID,
Snippet: snippet,
Status: t.Status,
})
}
}
// 2. Search documents (non-report .md files)
docs := scanMarkdownFiles(root)
for _, doc := range docs {
// Skip task files and reports (handled separately)
if strings.HasPrefix(doc.Path, "TASKS/") {
continue
}
content, err := os.ReadFile(filepath.Join(root, doc.Path))
if err != nil {
continue
}
if containsInsensitive(string(content), query) || containsInsensitive(doc.Path, query) {
snippet := extractSnippet(string(content), query)
results = append(results, searchResult{
Type: "doc",
Icon: "📄",
Title: doc.Path,
Link: "/docs/" + doc.Path,
Snippet: snippet,
})
}
}
// 3. Search reports
reportsDir := filepath.Join(s.Config.TasksDir, "reports")
entries, _ := os.ReadDir(reportsDir)
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
content, err := os.ReadFile(filepath.Join(reportsDir, entry.Name()))
if err != nil {
continue
}
if containsInsensitive(string(content), query) || containsInsensitive(entry.Name(), query) {
// Extract task ID from report filename (T01-report.md → T01)
taskID := strings.TrimSuffix(entry.Name(), "-report.md")
snippet := extractSnippet(string(content), query)
results = append(results, searchResult{
Type: "report",
Icon: "📊",
Title: entry.Name(),
Link: "/report/" + taskID,
Snippet: snippet,
})
}
}
// Limit results
if len(results) > 20 {
results = results[:20]
}
data := searchResultsData{
Query: query,
Results: results,
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, renderSearchResults(data))
}
// matchTask checks if a task matches the query (case insensitive).
func matchTask(t supervisor.Task, query string) bool {
q := strings.ToLower(query)
return containsInsensitive(t.ID, query) ||
containsInsensitive(t.Title, query) ||
containsInsensitive(t.Description, query) ||
containsInsensitive(t.Agent, query) ||
strings.ToLower(t.Status) == q
}
// taskSnippet returns a snippet from the task for display.
func taskSnippet(t supervisor.Task, query string) string {
// Try to find match in description first
if t.Description != "" && containsInsensitive(t.Description, query) {
return extractSnippet(t.Description, query)
}
// Fall back to description start
if t.Description != "" {
desc := t.Description
if len(desc) > 100 {
desc = desc[:100] + "..."
}
return desc
}
return t.Agent + " · " + t.Model
}
// containsInsensitive checks if s contains substr (case insensitive).
func containsInsensitive(s, substr string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
}
// extractSnippet extracts a snippet around the first match with context.
func extractSnippet(content, query string) string {
lower := strings.ToLower(content)
q := strings.ToLower(query)
idx := strings.Index(lower, q)
if idx == -1 {
// Return first 100 chars
if len(content) > 100 {
return content[:100] + "..."
}
return content
}
// Get context: 40 chars before and 60 chars after
start := idx - 40
if start < 0 {
start = 0
}
end := idx + len(query) + 60
if end > len(content) {
end = len(content)
}
snippet := content[start:end]
// Clean up: remove newlines, trim
snippet = strings.ReplaceAll(snippet, "\n", " ")
snippet = strings.ReplaceAll(snippet, "\r", "")
snippet = strings.TrimSpace(snippet)
if start > 0 {
snippet = "..." + snippet
}
if end < len(content) {
snippet = snippet + "..."
}
return snippet
}