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 }