192 lines
4.7 KiB
Go
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
|
|
}
|