diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 503799b..eacfbf5 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -547,3 +547,104 @@ func (h *Handlers) TreeAPIHandler(c *gin.Context) { c.JSON(http.StatusOK, notesTree) } + +// SearchHandler performs a simple full-text search across markdown and allowed text files +// within the notes directory, honoring skipped directories. +// GET /api/search?q=term +func (h *Handlers) SearchHandler(c *gin.Context) { + query := strings.TrimSpace(c.Query("q")) + if query == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing query"}) + return + } + + // Case-insensitive search + qLower := strings.ToLower(query) + + type Snippet struct { + Line int `json:"line"` + Preview string `json:"preview"` + } + type Result struct { + Path string `json:"path"` + Type string `json:"type"` // "md" or "text" + Snippets []Snippet `json:"snippets"` + } + + results := make([]Result, 0, 32) + + // Walk the notes directory + err := filepath.Walk(h.config.NotesDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // skip on error + } + if info.IsDir() { + // Compute relative dir path to check skip list + rel, _ := filepath.Rel(h.config.NotesDir, path) + rel = filepath.ToSlash(rel) + if rel == "." { + rel = "" + } + if utils.IsPathInSkippedDirs(rel, h.config.NotesDirSkip) { + return filepath.SkipDir + } + return nil + } + + // Compute relative file path + relPath, _ := filepath.Rel(h.config.NotesDir, path) + relPath = filepath.ToSlash(relPath) + + // Skip disallowed files + ext := strings.ToLower(filepath.Ext(relPath)) + isMD := ext == ".md" + if !isMD && !models.IsAllowedFile(relPath, h.config.AllowedFileExtensions) { + return nil + } + + // Read file content (limit size to prevent huge memory usage) + data, readErr := os.ReadFile(path) + if readErr != nil { + return nil + } + content := string(data) + contentLower := strings.ToLower(content) + if !strings.Contains(contentLower, qLower) { + return nil + } + + // Build snippets: show up to 3 matches with 2 lines of context + lines := strings.Split(content, "\n") + linesLower := strings.Split(contentLower, "\n") + snippets := make([]Snippet, 0, 3) + for i := 0; i < len(linesLower) && len(snippets) < 3; i++ { + if strings.Contains(linesLower[i], qLower) { + start := i - 2 + if start < 0 { + start = 0 + } + end := i + 2 + if end >= len(lines) { + end = len(lines) - 1 + } + // Join preview lines + preview := strings.Join(lines[start:end+1], "\n") + snippets = append(snippets, Snippet{Line: i + 1, Preview: preview}) + } + } + + rtype := "text" + if isMD { + rtype = "md" + } + results = append(results, Result{Path: relPath, Type: rtype, Snippets: snippets}) + return nil + }) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"query": query, "results": results}) +} diff --git a/internal/server/server.go b/internal/server/server.go index 0cd4f64..a6456c5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -96,6 +96,7 @@ func (s *Server) setupRoutes() { // API routes s.router.GET("/api/tree", h.TreeAPIHandler) + s.router.GET("/api/search", h.SearchHandler) } func (s *Server) setupStaticFiles() { diff --git a/web/templates/base.html b/web/templates/base.html index 7722a83..614cabf 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -265,7 +265,10 @@

{{.app_name}}

- + + +