package projectindex import ( "context" "strings" ) // contextRadius is the number of lines shown on each side of a matched line in // LineMatch.Context. const contextRadius = 2 // LineMatch is a single matched line inside a chapter's content plus its // surrounding context. LineNumber is 1-based within the chapter's Content. type LineMatch struct { LineNumber int Text string Context string } // ChapterHit is a chapter whose content matched a keyword, with the located // lines (and their context) that matched. type ChapterHit struct { Chapter *Chapter Matches []LineMatch } // SearchResult is the hierarchical result of a Search. Project carries the // matched project index entry + description (layer 1/2), set only when the // keyword hits the project title or desc; Chapters carries the matched chapters // (layer 3) with located lines. When nothing matches, Project is nil and // Chapters is empty, with no error. type SearchResult struct { Project *Project Chapters []ChapterHit } // Search looks up keyword within a single project, walking the hierarchy // 项目索引 → 项目描述 → 章节内容. It first loads the project (ErrProjectNotFound // when it does not exist), sets SearchResult.Project when the keyword hits the // project title or description, then scans every chapter for content hits, // producing located lines plus their context. A blank keyword returns // ErrMissingKeyword; a keyword that hits nothing returns a non-nil SearchResult // with Project and Chapters unset. func (s *Store) Search(ctx context.Context, projectID, keyword string) (*SearchResult, error) { kw := strings.ToLower(strings.TrimSpace(keyword)) if kw == "" { return nil, ErrMissingKeyword } proj, err := s.GetProject(ctx, projectID) if err != nil { return nil, err } result := &SearchResult{} if containsFold(proj.Title, kw) || containsFold(proj.Desc, kw) { result.Project = proj } chapters, err := s.ListChapters(ctx, projectID) if err != nil { return nil, err } for i := range chapters { matches := findLineMatches(chapters[i].Content, kw) if len(matches) == 0 { continue } ch := chapters[i] result.Chapters = append(result.Chapters, ChapterHit{Chapter: &ch, Matches: matches}) } return result, nil } // containsFold reports whether s (case-insensitively) contains sub, which must // already be lowercased by the caller. func containsFold(s, sub string) bool { return strings.Contains(strings.ToLower(s), sub) } // findLineMatches returns one LineMatch per content line that (case-insensitively) // contains keyword. Context is the surrounding contextRadius lines on each side, // joined by newlines and including the matched line. func findLineMatches(content, keyword string) []LineMatch { lines := strings.Split(content, "\n") matches := make([]LineMatch, 0) for i, line := range lines { if strings.Contains(strings.ToLower(line), keyword) { matches = append(matches, LineMatch{ LineNumber: i + 1, Text: line, Context: buildContext(lines, i), }) } } return matches } // buildContext returns lines[start..end] around idx (inclusive) joined by \n, // clamped to the slice bounds so the context never overflows. func buildContext(lines []string, idx int) string { start := idx - contextRadius if start < 0 { start = 0 } end := idx + contextRadius if end >= len(lines) { end = len(lines) - 1 } return strings.Join(lines[start:end+1], "\n") }