Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,9 @@ notesmd-cli search-content "search term" --no-interactive
# Prints JSON for scripts (implies non-interactive mode)
notesmd-cli search-content "search term" --format json

# Paginated results (default page size: 25, max: 100)
notesmd-cli search-content "search term" --format json --page 1 --page-size 50

```

### List Vault Contents
Expand Down
14 changes: 14 additions & 0 deletions cmd/search_content.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ func buildSearchContentOptions(cmd *cobra.Command, vault obsidian.VaultManager,
return actions.SearchContentOptions{}, err
}

page, err := cmd.Flags().GetInt("page")
if err != nil {
return actions.SearchContentOptions{}, err
}

pageSize, err := cmd.Flags().GetInt("page-size")
if err != nil {
return actions.SearchContentOptions{}, err
}

useEditor := resolveUseEditor(cmd, vault)

return actions.SearchContentOptions{
Expand All @@ -54,6 +64,8 @@ func buildSearchContentOptions(cmd *cobra.Command, vault obsidian.VaultManager,
Format: format,
InteractiveTerminal: interactiveTerminal,
Output: os.Stdout,
Page: page,
PageSize: pageSize,
}, nil
}

Expand All @@ -66,5 +78,7 @@ func init() {
searchContentCmd.Flags().BoolP("editor", "e", false, "open in editor instead of Obsidian")
searchContentCmd.Flags().Bool("no-interactive", false, "disable interactive selection and print results to stdout")
searchContentCmd.Flags().String("format", "text", "output format for non-interactive mode: text|json")
searchContentCmd.Flags().Int("page", 0, "page number for paginated results (enables pagination)")
searchContentCmd.Flags().Int("page-size", 0, "results per page, max 100 (default 25 when pagination is enabled)")
rootCmd.AddCommand(searchContentCmd)
}
6 changes: 6 additions & 0 deletions cmd/search_content_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ func newSearchContentOptionsTestCmd() *cobra.Command {
c.Flags().BoolP("editor", "e", false, "")
c.Flags().Bool("no-interactive", false, "")
c.Flags().String("format", "text", "")
c.Flags().Int("page", 0, "")
c.Flags().Int("page-size", 0, "")
return c
}

Expand All @@ -65,8 +67,12 @@ func TestSearchContentCommandFlagsWired(t *testing.T) {
assert.NotNil(t, searchContentCmd.Flags().Lookup("format"))
assert.NotNil(t, searchContentCmd.Flags().Lookup("editor"))
assert.NotNil(t, searchContentCmd.Flags().Lookup("vault"))
assert.NotNil(t, searchContentCmd.Flags().Lookup("page"))
assert.NotNil(t, searchContentCmd.Flags().Lookup("page-size"))

assert.Equal(t, "text", searchContentCmd.Flags().Lookup("format").DefValue)
assert.Equal(t, "0", searchContentCmd.Flags().Lookup("page").DefValue)
assert.Equal(t, "0", searchContentCmd.Flags().Lookup("page-size").DefValue)
assert.Contains(t, searchContentCmd.Aliases, "sc")
}

Expand Down
121 changes: 111 additions & 10 deletions pkg/actions/search_content.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type SearchContentOptions struct {
Format string
InteractiveTerminal bool
Output io.Writer
Page int
PageSize int
}

type searchContentJSONMatch struct {
Expand All @@ -33,6 +35,20 @@ type searchContentJSONMatch struct {
MatchType string `json:"match_type"`
}

type searchContentPaginatedJSON struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalResults int `json:"total_results"`
ReturnedResults int `json:"returned_results"`
HasMore bool `json:"has_more"`
Results []searchContentJSONMatch `json:"results"`
}

const (
defaultPageSize = 25
maxPageSize = 100
)

// SearchNotesContent preserves backward-compatible interactive behavior.
func SearchNotesContent(vault obsidian.VaultManager, note obsidian.NoteManager, uri obsidian.UriManager, fuzzyFinder obsidian.FuzzyFinderManager, searchTerm string, useEditor bool) error {
return SearchNotesContentWithOptions(vault, note, uri, fuzzyFinder, searchTerm, SearchContentOptions{
Expand Down Expand Up @@ -84,7 +100,7 @@ func SearchNotesContentWithOptions(vault obsidian.VaultManager, note obsidian.No
}

if nonInteractiveMode {
return printMatches(matches, searchTerm, format, output)
return printMatches(matches, searchTerm, format, output, options)
}

if len(matches) == 0 {
Expand Down Expand Up @@ -134,6 +150,9 @@ func shouldUseNonInteractiveMode(options SearchContentOptions, format string) bo
if format == searchContentFormatJSON {
return true
}
if isPaginationRequested(options) {
return true
}
return !options.InteractiveTerminal
}

Expand All @@ -151,31 +170,113 @@ func normalizeSearchContentFormat(format string) (string, error) {
}
}

func printMatches(matches []obsidian.NoteMatch, searchTerm string, format string, output io.Writer) error {
type paginationResult struct {
items []obsidian.NoteMatch
page int
pageSize int
totalPages int
hasMore bool
}

func paginateMatches(matches []obsidian.NoteMatch, options SearchContentOptions) paginationResult {
page := options.Page
pageSize := options.PageSize

if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = defaultPageSize
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}

total := len(matches)
totalPages := (total + pageSize - 1) / pageSize
if totalPages == 0 {
totalPages = 1
}
if page > totalPages {
page = totalPages
}

start := (page - 1) * pageSize
if start >= total {
return paginationResult{page: page, pageSize: pageSize, totalPages: totalPages}
}
end := start + pageSize
if end > total {
end = total
}
return paginationResult{
items: matches[start:end],
page: page,
pageSize: pageSize,
totalPages: totalPages,
hasMore: end < total,
}
}

func isPaginationRequested(options SearchContentOptions) bool {
return options.Page > 0 || options.PageSize > 0
}

func toJSONMatches(matches []obsidian.NoteMatch) []searchContentJSONMatch {
result := make([]searchContentJSONMatch, 0, len(matches))
for _, match := range matches {
result = append(result, searchContentJSONMatch{
File: match.FilePath,
Line: match.LineNumber,
Content: match.MatchLine,
MatchType: getMatchType(match),
})
}
return result
}

func printMatches(matches []obsidian.NoteMatch, searchTerm string, format string, output io.Writer, options SearchContentOptions) error {
paginate := isPaginationRequested(options)

switch format {
case searchContentFormatText:
if len(matches) == 0 {
fmt.Fprintf(os.Stderr, "No notes found containing '%s'\n", searchTerm)
return nil
}

if paginate {
pg := paginateMatches(matches, options)
for _, match := range pg.items {
_, _ = fmt.Fprintln(output, formatMatchForList(match))
}
_, _ = fmt.Fprintf(output, "-- Page %d/%d (%d of %d results) --\n", pg.page, pg.totalPages, len(pg.items), len(matches))
return nil
}

for _, match := range matches {
_, _ = fmt.Fprintln(output, formatMatchForList(match))
}
return nil
case searchContentFormatJSON:
result := make([]searchContentJSONMatch, 0, len(matches))
for _, match := range matches {
result = append(result, searchContentJSONMatch{
File: match.FilePath,
Line: match.LineNumber,
Content: match.MatchLine,
MatchType: getMatchType(match),
if paginate {
pg := paginateMatches(matches, options)
result := toJSONMatches(pg.items)
encoder := json.NewEncoder(output)
encoder.SetEscapeHTML(false)
return encoder.Encode(searchContentPaginatedJSON{
Page: pg.page,
PageSize: pg.pageSize,
TotalResults: len(matches),
ReturnedResults: len(result),
HasMore: pg.hasMore,
Results: result,
})
}

encoder := json.NewEncoder(output)
encoder.SetEscapeHTML(false)
return encoder.Encode(result)
return encoder.Encode(toJSONMatches(matches))
default:
return fmt.Errorf("unsupported output format: %s", format)
}
Expand Down
Loading
Loading