diff --git a/README.md b/README.md index 8135168..2637a51 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/search_content.go b/cmd/search_content.go index 92ffede..d9a8556 100644 --- a/cmd/search_content.go +++ b/cmd/search_content.go @@ -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{ @@ -54,6 +64,8 @@ func buildSearchContentOptions(cmd *cobra.Command, vault obsidian.VaultManager, Format: format, InteractiveTerminal: interactiveTerminal, Output: os.Stdout, + Page: page, + PageSize: pageSize, }, nil } @@ -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) } diff --git a/cmd/search_content_test.go b/cmd/search_content_test.go index 647d6f4..bbe7649 100644 --- a/cmd/search_content_test.go +++ b/cmd/search_content_test.go @@ -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 } @@ -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") } diff --git a/pkg/actions/search_content.go b/pkg/actions/search_content.go index 8e7310d..7619556 100644 --- a/pkg/actions/search_content.go +++ b/pkg/actions/search_content.go @@ -24,6 +24,8 @@ type SearchContentOptions struct { Format string InteractiveTerminal bool Output io.Writer + Page int + PageSize int } type searchContentJSONMatch struct { @@ -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{ @@ -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 { @@ -134,6 +150,9 @@ func shouldUseNonInteractiveMode(options SearchContentOptions, format string) bo if format == searchContentFormatJSON { return true } + if isPaginationRequested(options) { + return true + } return !options.InteractiveTerminal } @@ -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) } diff --git a/pkg/actions/search_content_test.go b/pkg/actions/search_content_test.go index de93018..ee1f6e6 100644 --- a/pkg/actions/search_content_test.go +++ b/pkg/actions/search_content_test.go @@ -33,13 +33,6 @@ func (m *CustomMockNoteForSingleMatch) FindBacklinks(string, string) ([]obsidian return nil, nil } -type searchContentJSONMatch struct { - File string `json:"file"` - Line int `json:"line"` - Content string `json:"content"` - MatchType string `json:"match_type"` -} - func defaultOptions(output *bytes.Buffer) actions.SearchContentOptions { return actions.SearchContentOptions{ Format: "text", @@ -264,14 +257,14 @@ func TestSearchNotesContent(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 0, uri.ExecuteCalls) - var result []searchContentJSONMatch + var result []map[string]any decodeErr := json.Unmarshal(output.Bytes(), &result) assert.NoError(t, decodeErr) assert.Len(t, result, 2) - assert.Equal(t, "note1.md", result[0].File) - assert.Equal(t, 5, result[0].Line) - assert.Equal(t, "example match line", result[0].Content) - assert.Equal(t, "content", result[0].MatchType) + assert.Equal(t, "note1.md", result[0]["file"]) + assert.Equal(t, float64(5), result[0]["line"]) + assert.Equal(t, "example match line", result[0]["content"]) + assert.Equal(t, "content", result[0]["match_type"]) }) t.Run("JSON format with no matches prints empty array", func(t *testing.T) { @@ -354,4 +347,161 @@ func TestSearchNotesContent(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "invalid format") }) + + t.Run("JSON pagination wraps results in envelope", func(t *testing.T) { + vault := mocks.MockVaultOperator{Name: "myVault"} + uri := mocks.MockUriManager{} + note := mocks.MockNoteManager{} + fuzzyFinder := mocks.MockFuzzyFinder{} + output := &bytes.Buffer{} + + options := defaultOptions(output) + options.Format = "json" + options.NoInteractive = true + options.Page = 1 + options.PageSize = 1 + + err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) + assert.NoError(t, err) + + var result map[string]any + decodeErr := json.Unmarshal(output.Bytes(), &result) + assert.NoError(t, decodeErr) + assert.Equal(t, float64(1), result["page"]) + assert.Equal(t, float64(1), result["page_size"]) + assert.Equal(t, float64(2), result["total_results"]) + assert.Equal(t, float64(1), result["returned_results"]) + assert.Equal(t, true, result["has_more"]) + assert.Len(t, result["results"], 1) + }) + + t.Run("JSON pagination page 2", func(t *testing.T) { + vault := mocks.MockVaultOperator{Name: "myVault"} + uri := mocks.MockUriManager{} + note := mocks.MockNoteManager{} + fuzzyFinder := mocks.MockFuzzyFinder{} + output := &bytes.Buffer{} + + options := defaultOptions(output) + options.Format = "json" + options.NoInteractive = true + options.Page = 2 + options.PageSize = 1 + + err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) + assert.NoError(t, err) + + var result map[string]any + decodeErr := json.Unmarshal(output.Bytes(), &result) + assert.NoError(t, decodeErr) + assert.Equal(t, float64(2), result["page"]) + assert.Equal(t, false, result["has_more"]) + assert.Len(t, result["results"], 1) + }) + + t.Run("Text pagination appends footer", func(t *testing.T) { + vault := mocks.MockVaultOperator{Name: "myVault"} + uri := mocks.MockUriManager{} + note := mocks.MockNoteManager{} + fuzzyFinder := mocks.MockFuzzyFinder{} + output := &bytes.Buffer{} + + options := defaultOptions(output) + options.NoInteractive = true + options.Page = 1 + options.PageSize = 1 + + err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) + assert.NoError(t, err) + assert.Contains(t, output.String(), "-- Page 1/2 (1 of 2 results) --") + }) + + t.Run("Without pagination flags JSON output is flat array", func(t *testing.T) { + vault := mocks.MockVaultOperator{Name: "myVault"} + uri := mocks.MockUriManager{} + note := mocks.MockNoteManager{} + fuzzyFinder := mocks.MockFuzzyFinder{} + output := &bytes.Buffer{} + + options := defaultOptions(output) + options.Format = "json" + options.NoInteractive = true + + err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) + assert.NoError(t, err) + + var result []map[string]any + decodeErr := json.Unmarshal(output.Bytes(), &result) + assert.NoError(t, decodeErr) + assert.Len(t, result, 2) + }) + + t.Run("Out-of-range page is clamped to last page", func(t *testing.T) { + vault := mocks.MockVaultOperator{Name: "myVault"} + uri := mocks.MockUriManager{} + note := mocks.MockNoteManager{} + fuzzyFinder := mocks.MockFuzzyFinder{} + output := &bytes.Buffer{} + + options := defaultOptions(output) + options.Format = "json" + options.NoInteractive = true + options.Page = 999 + options.PageSize = 25 + + err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) + assert.NoError(t, err) + + var result map[string]any + decodeErr := json.Unmarshal(output.Bytes(), &result) + assert.NoError(t, decodeErr) + assert.Equal(t, float64(1), result["page"]) + assert.Equal(t, float64(2), result["returned_results"]) + assert.Equal(t, false, result["has_more"]) + }) + + t.Run("Page-size only defaults to page 1", func(t *testing.T) { + vault := mocks.MockVaultOperator{Name: "myVault"} + uri := mocks.MockUriManager{} + note := mocks.MockNoteManager{} + fuzzyFinder := mocks.MockFuzzyFinder{} + output := &bytes.Buffer{} + + options := defaultOptions(output) + options.Format = "json" + options.PageSize = 1 + + err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) + assert.NoError(t, err) + + var result map[string]any + decodeErr := json.Unmarshal(output.Bytes(), &result) + assert.NoError(t, decodeErr) + assert.Equal(t, float64(1), result["page"]) + assert.Equal(t, float64(1), result["returned_results"]) + assert.Equal(t, true, result["has_more"]) + }) + + t.Run("Pagination flags imply non-interactive mode", func(t *testing.T) { + vault := mocks.MockVaultOperator{Name: "myVault"} + uri := mocks.MockUriManager{} + note := mocks.MockNoteManager{} + fuzzyFinder := mocks.MockFuzzyFinder{} + output := &bytes.Buffer{} + + options := defaultOptions(output) + options.Format = "json" + options.InteractiveTerminal = true + options.Page = 1 + options.PageSize = 10 + + err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) + assert.NoError(t, err) + assert.Equal(t, 0, uri.ExecuteCalls) + + var result map[string]any + decodeErr := json.Unmarshal(output.Bytes(), &result) + assert.NoError(t, decodeErr) + assert.NotNil(t, result["page"]) + }) } diff --git a/pkg/obsidian/note.go b/pkg/obsidian/note.go index 690b75b..f337447 100644 --- a/pkg/obsidian/note.go +++ b/pkg/obsidian/note.go @@ -12,7 +12,10 @@ import ( "strings" ) -type Note struct { +type Note struct{} + +func isHiddenDir(d fs.DirEntry) bool { + return d.IsDir() && d.Name() != "." && strings.HasPrefix(d.Name(), ".") } type NoteMatch struct { @@ -184,6 +187,9 @@ func (m *Note) GetNotesList(vaultPath string) ([]string, error) { if err != nil { return err } + if isHiddenDir(d) { + return filepath.SkipDir + } relPath, err := filepath.Rel(vaultPath, path) if err != nil { return err @@ -214,6 +220,9 @@ func (m *Note) SearchNotesWithSnippets(vaultPath string, query string) ([]NoteMa if err != nil { return err } + if isHiddenDir(d) { + return filepath.SkipDir + } relPath, relErr := filepath.Rel(vaultPath, path) if relErr != nil { return relErr @@ -353,6 +362,9 @@ func (m *Note) FindBacklinks(vaultPath, noteName string) ([]NoteMatch, error) { if err != nil { return err } + if isHiddenDir(d) { + return filepath.SkipDir + } relPath, err := filepath.Rel(vaultPath, path) if err != nil {