feat(memory/projectindex): 项目索引分层(索引+描述+章节,Search 定位行+上下文,级联删除)
This commit is contained in:
1 parent
d90ec7bd4f
commit
ef79edd96a
5 files changed
+1010
No files matched your search
@@ -0,0 +1,108 @@
|
||||
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")
|
||||
}
|
||||
Reference in new issue
Block a user