109 lines
3.4 KiB
Go
109 lines
3.4 KiB
Go
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")
|
|
}
|