From eb87e7b5f2abfd8482c02c88540676f3d46722c5 Mon Sep 17 00:00:00 2001 From: Kartikay Jainwal Date: Fri, 6 Mar 2026 16:55:11 +0000 Subject: [PATCH 1/2] feat: respect Obsidian excluded files in search --- pkg/obsidian/config.go | 21 +++++++++- pkg/obsidian/config_test.go | 54 +++++++++++++++++++++++++ pkg/obsidian/note.go | 45 +++++++++++++++------ pkg/obsidian/note_test.go | 81 +++++++++++++++++++++++++++++++++++++ pkg/obsidian/utils.go | 15 +++++++ pkg/obsidian/utils_test.go | 27 +++++++++++++ 6 files changed, 229 insertions(+), 14 deletions(-) diff --git a/pkg/obsidian/config.go b/pkg/obsidian/config.go index bbe1448..f686e7f 100644 --- a/pkg/obsidian/config.go +++ b/pkg/obsidian/config.go @@ -10,8 +10,9 @@ import ( // ObsidianAppConfig represents relevant fields from .obsidian/app.json. type ObsidianAppConfig struct { - NewFileLocation string `json:"newFileLocation"` - NewFileFolderPath string `json:"newFileFolderPath"` + NewFileLocation string `json:"newFileLocation"` + NewFileFolderPath string `json:"newFileFolderPath"` + UserIgnoreFilters []string `json:"userIgnoreFilters"` } // DailyNotesConfig represents relevant fields from .obsidian/daily-notes.json. @@ -21,6 +22,22 @@ type DailyNotesConfig struct { Template string `json:"template"` } +// ExcludedPaths reads the userIgnoreFilters from .obsidian/app.json and returns +// the list of path patterns to exclude. Returns nil if the config is absent or unreadable. +func ExcludedPaths(vaultPath string) []string { + data, err := os.ReadFile(filepath.Join(vaultPath, ".obsidian", "app.json")) + if err != nil { + return nil + } + + var config ObsidianAppConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil + } + + return config.UserIgnoreFilters +} + // DefaultNoteFolder reads the configured default folder for new notes from // .obsidian/app.json. Returns "" if not configured or unreadable (caller // should use vault root). diff --git a/pkg/obsidian/config_test.go b/pkg/obsidian/config_test.go index a8ad122..d0f926a 100644 --- a/pkg/obsidian/config_test.go +++ b/pkg/obsidian/config_test.go @@ -199,6 +199,60 @@ func TestReadDailyNotesConfig(t *testing.T) { }) } +func TestExcludedPaths(t *testing.T) { + t.Run("Returns filters from app.json", func(t *testing.T) { + tmpDir := t.TempDir() + obsDir := filepath.Join(tmpDir, ".obsidian") + if err := os.MkdirAll(obsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(obsDir, "app.json"), []byte(`{ + "userIgnoreFilters": ["Archive", "Templates/", "Private/Notes"] + }`), 0644); err != nil { + t.Fatal(err) + } + + result := obsidian.ExcludedPaths(tmpDir) + assert.Equal(t, []string{"Archive", "Templates/", "Private/Notes"}, result) + }) + + t.Run("Returns nil when config is absent", func(t *testing.T) { + tmpDir := t.TempDir() + result := obsidian.ExcludedPaths(tmpDir) + assert.Nil(t, result) + }) + + t.Run("Returns nil on invalid JSON", func(t *testing.T) { + tmpDir := t.TempDir() + obsDir := filepath.Join(tmpDir, ".obsidian") + if err := os.MkdirAll(obsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(obsDir, "app.json"), []byte(`not json`), 0644); err != nil { + t.Fatal(err) + } + + result := obsidian.ExcludedPaths(tmpDir) + assert.Nil(t, result) + }) + + t.Run("Returns nil when userIgnoreFilters absent", func(t *testing.T) { + tmpDir := t.TempDir() + obsDir := filepath.Join(tmpDir, ".obsidian") + if err := os.MkdirAll(obsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(obsDir, "app.json"), []byte(`{ + "newFileLocation": "root" + }`), 0644); err != nil { + t.Fatal(err) + } + + result := obsidian.ExcludedPaths(tmpDir) + assert.Nil(t, result) + }) +} + func TestMomentToGoFormat(t *testing.T) { tests := []struct { name string diff --git a/pkg/obsidian/note.go b/pkg/obsidian/note.go index 89aaaef..690b75b 100644 --- a/pkg/obsidian/note.go +++ b/pkg/obsidian/note.go @@ -178,16 +178,23 @@ func (m *Note) UpdateLinks(vaultPath string, oldNoteName string, newNoteName str } func (m *Note) GetNotesList(vaultPath string) ([]string, error) { + excluded := ExcludedPaths(vaultPath) var notes []string err := filepath.WalkDir(vaultPath, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } - if !d.IsDir() && strings.HasSuffix(d.Name(), ".md") { - relPath, err := filepath.Rel(vaultPath, path) - if err != nil { - return err + relPath, err := filepath.Rel(vaultPath, path) + if err != nil { + return err + } + if relPath != "." && IsExcluded(relPath, excluded) { + if d.IsDir() { + return filepath.SkipDir } + return nil + } + if !d.IsDir() && strings.HasSuffix(d.Name(), ".md") { notes = append(notes, relPath) } return nil @@ -199,6 +206,7 @@ func (m *Note) GetNotesList(vaultPath string) ([]string, error) { } func (m *Note) SearchNotesWithSnippets(vaultPath string, query string) ([]NoteMatch, error) { + excluded := ExcludedPaths(vaultPath) var matches []NoteMatch queryLower := strings.ToLower(query) @@ -206,12 +214,17 @@ func (m *Note) SearchNotesWithSnippets(vaultPath string, query string) ([]NoteMa if err != nil { return err } - if !d.IsDir() && strings.HasSuffix(d.Name(), ".md") { - relPath, err := filepath.Rel(vaultPath, path) - if err != nil { - return err + relPath, relErr := filepath.Rel(vaultPath, path) + if relErr != nil { + return relErr + } + if relPath != "." && IsExcluded(relPath, excluded) { + if d.IsDir() { + return filepath.SkipDir } - + return nil + } + if !d.IsDir() && strings.HasSuffix(d.Name(), ".md") { fileNameMatches := strings.Contains(strings.ToLower(relPath), queryLower) var hasContentMatch bool @@ -324,6 +337,7 @@ func findMatchingLines(content []byte, patternsLower [][]byte) []NoteMatch { func (m *Note) FindBacklinks(vaultPath, noteName string) ([]NoteMatch, error) { noteName = RemoveMdSuffix(noteName) + excluded := ExcludedPaths(vaultPath) // Generate patterns and convert to lowercase bytes once patterns := GenerateBacklinkSearchPatterns(noteName) @@ -339,14 +353,21 @@ func (m *Note) FindBacklinks(vaultPath, noteName string) ([]NoteMatch, error) { if err != nil { return err } - if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") { - return nil - } relPath, err := filepath.Rel(vaultPath, path) if err != nil { return err } + if relPath != "." && IsExcluded(relPath, excluded) { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") { + return nil + } // Skip the note itself (normalize for comparison) if RemoveMdSuffix(normalizePathSeparators(relPath)) == noteName { diff --git a/pkg/obsidian/note_test.go b/pkg/obsidian/note_test.go index d61a92f..46357ff 100644 --- a/pkg/obsidian/note_test.go +++ b/pkg/obsidian/note_test.go @@ -477,6 +477,46 @@ func TestNote_GetNotesList(t *testing.T) { assert.NoError(t, err, "Expected no error when non-Markdown files are present") assert.Empty(t, notes, "Expected empty notes list when no Markdown files are present") }) + + t.Run("Excludes files and folders matching userIgnoreFilters", func(t *testing.T) { + // Arrange + tmpDir := t.TempDir() + archiveDir := filepath.Join(tmpDir, "Archive") + assert.NoError(t, os.MkdirAll(archiveDir, 0755)) + assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "visible.md"), []byte(""), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(archiveDir, "old.md"), []byte(""), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "excluded-file.md"), []byte(""), 0644)) + writeObsidianAppJSON(t, tmpDir, []string{"Archive", "excluded-file.md"}) + + noteManager := obsidian.Note{} + + // Act + notes, err := noteManager.GetNotesList(tmpDir) + + // Assert + assert.NoError(t, err) + assert.Equal(t, []string{"visible.md"}, notes) + }) +} + +func writeObsidianAppJSON(t *testing.T, vaultDir string, filters []string) { + t.Helper() + obsDir := filepath.Join(vaultDir, ".obsidian") + if err := os.MkdirAll(obsDir, 0755); err != nil { + t.Fatal(err) + } + filterJSON := "[" + for i, f := range filters { + if i > 0 { + filterJSON += "," + } + filterJSON += `"` + f + `"` + } + filterJSON += "]" + content := `{"userIgnoreFilters":` + filterJSON + `}` + if err := os.WriteFile(filepath.Join(obsDir, "app.json"), []byte(content), 0644); err != nil { + t.Fatal(err) + } } func TestSearchNotesWithSnippets(t *testing.T) { @@ -592,6 +632,25 @@ func TestSearchNotesWithSnippets(t *testing.T) { assert.Empty(t, matches) }) + t.Run("Excludes paths matching userIgnoreFilters", func(t *testing.T) { + // Arrange + tempDir := t.TempDir() + archiveDir := filepath.Join(tempDir, "Archive") + assert.NoError(t, os.MkdirAll(archiveDir, 0755)) + assert.NoError(t, os.WriteFile(filepath.Join(tempDir, "visible.md"), []byte("test content"), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(archiveDir, "old.md"), []byte("test content"), 0644)) + writeObsidianAppJSON(t, tempDir, []string{"Archive"}) + + // Act + note := obsidian.Note{} + matches, err := note.SearchNotesWithSnippets(tempDir, "test") + + // Assert + assert.NoError(t, err) + assert.Len(t, matches, 1) + assert.Equal(t, "visible.md", matches[0].FilePath) + }) + t.Run("Search with long lines gets truncated", func(t *testing.T) { // Arrange tempDir := t.TempDir() @@ -756,6 +815,28 @@ func TestFindBacklinks(t *testing.T) { assert.Len(t, matches, 1) }) + t.Run("Excludes paths matching userIgnoreFilters", func(t *testing.T) { + // Arrange + tempDir := t.TempDir() + archiveDir := filepath.Join(tempDir, "Archive") + assert.NoError(t, os.MkdirAll(archiveDir, 0755)) + + assert.NoError(t, os.WriteFile(filepath.Join(tempDir, "target.md"), []byte("Target"), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(tempDir, "linker.md"), []byte("Links to [[target]]"), 0644)) + // This file is in an excluded folder and should not appear in results + assert.NoError(t, os.WriteFile(filepath.Join(archiveDir, "archived.md"), []byte("Also links [[target]]"), 0644)) + writeObsidianAppJSON(t, tempDir, []string{"Archive"}) + + // Act + note := obsidian.Note{} + matches, err := note.FindBacklinks(tempDir, "target") + + // Assert + assert.NoError(t, err) + assert.Len(t, matches, 1) + assert.Equal(t, "linker.md", matches[0].FilePath) + }) + t.Run("Find links in subdirectories", func(t *testing.T) { // Arrange tempDir := t.TempDir() diff --git a/pkg/obsidian/utils.go b/pkg/obsidian/utils.go index e3962e4..6f511a1 100644 --- a/pkg/obsidian/utils.go +++ b/pkg/obsidian/utils.go @@ -130,6 +130,21 @@ func ReplaceContent(content []byte, replacements map[string]string) []byte { return content } +// IsExcluded reports whether relPath (a slash-separated path relative to the +// vault root) matches any of the Obsidian userIgnoreFilters patterns. +// A pattern matches if the path equals the filter or is inside the filtered +// folder (i.e. has the filter as a path prefix). +func IsExcluded(relPath string, filters []string) bool { + normalized := filepath.ToSlash(relPath) + for _, filter := range filters { + filter = strings.TrimRight(filter, "/") + if normalized == filter || strings.HasPrefix(normalized, filter+"/") { + return true + } + } + return false +} + func ShouldSkipDirectoryOrFile(info os.FileInfo) bool { isDirectory := info.IsDir() isHidden := info.Name()[0] == '.' diff --git a/pkg/obsidian/utils_test.go b/pkg/obsidian/utils_test.go index 9a67de5..ec8e277 100644 --- a/pkg/obsidian/utils_test.go +++ b/pkg/obsidian/utils_test.go @@ -166,6 +166,33 @@ func TestReplaceContent(t *testing.T) { } +func TestIsExcluded(t *testing.T) { + tests := []struct { + name string + relPath string + filters []string + want bool + }{ + {"empty filters", "Archive/note.md", []string{}, false}, + {"nil filters", "Archive/note.md", nil, false}, + {"exact folder match", "Archive", []string{"Archive"}, true}, + {"file inside excluded folder", "Archive/note.md", []string{"Archive"}, true}, + {"nested file inside excluded folder", "Archive/2024/note.md", []string{"Archive"}, true}, + {"filter with trailing slash", "Templates/daily.md", []string{"Templates/"}, true}, + {"no match", "Notes/note.md", []string{"Archive", "Templates"}, false}, + {"partial folder name does not match", "Archives/note.md", []string{"Archive"}, false}, + {"exact file match", "Private/secret.md", []string{"Private/secret.md"}, true}, + {"multiple filters, one matches", "Templates/t.md", []string{"Archive", "Templates"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := obsidian.IsExcluded(tt.relPath, tt.filters) + assert.Equal(t, tt.want, got) + }) + } +} + func TestShouldSkipDirectoryOrFile(t *testing.T) { tests := []struct { testName string From be2c66887c73b75b444011ea02341f3d7df15525 Mon Sep 17 00:00:00 2001 From: Kartikay Jainwal Date: Fri, 6 Mar 2026 17:15:06 +0000 Subject: [PATCH 2/2] feat: add glob and ** pattern support for excluded files --- README.md | 9 +++++++++ pkg/obsidian/utils.go | 40 ++++++++++++++++++++++++++++++++++---- pkg/obsidian/utils_test.go | 12 ++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e18a9ef..9f83474 100644 --- a/README.md +++ b/README.md @@ -352,6 +352,15 @@ notesmd-cli frontmatter "{note-name}" --delete --key "draft" notesmd-cli frontmatter "{note-name}" --print --vault "{vault-name}" ``` +## Excluded Files + +The CLI respects Obsidian's **Excluded Files** setting (`Settings → Files & Links → Excluded Files`). + +- `search` — excluded notes won't appear in the fuzzy finder +- `search-content` — excluded folders won't be searched + +All other commands (`open`, `move`, `print`, `frontmatter`, etc.) still access excluded files as they refer to notes by name. + ## Contribution Fork the project, add your feature or fix and submit a pull request. You can also open an [issue](https://github.com/yakitrak/notesmd-cli/issues/new/choose) to report a bug or request a feature. diff --git a/pkg/obsidian/utils.go b/pkg/obsidian/utils.go index 6f511a1..787a69f 100644 --- a/pkg/obsidian/utils.go +++ b/pkg/obsidian/utils.go @@ -132,13 +132,45 @@ func ReplaceContent(content []byte, replacements map[string]string) []byte { // IsExcluded reports whether relPath (a slash-separated path relative to the // vault root) matches any of the Obsidian userIgnoreFilters patterns. -// A pattern matches if the path equals the filter or is inside the filtered -// folder (i.e. has the filter as a path prefix). +// Supported patterns: +// - Plain paths: "Archive", "Templates/" — prefix match +// - Globs: "*.pdf" — matches against each path segment +// - Double-star: "**/drafts" — matches at any depth func IsExcluded(relPath string, filters []string) bool { normalized := filepath.ToSlash(relPath) for _, filter := range filters { - filter = strings.TrimRight(filter, "/") - if normalized == filter || strings.HasPrefix(normalized, filter+"/") { + if matchFilter(normalized, filter) { + return true + } + } + return false +} + +func matchFilter(normalizedPath, filter string) bool { + filter = strings.TrimRight(filter, "/") + + // Plain path: prefix match + if !strings.ContainsAny(filter, "*?[") { + return normalizedPath == filter || strings.HasPrefix(normalizedPath, filter+"/") + } + + // "**/" prefix: match the remainder against all subpaths and segments + if strings.HasPrefix(filter, "**/") { + return matchPathOrSegments(normalizedPath, filter[3:]) + } + + // Simple glob (e.g. "*.pdf"): match against full path and each segment + return matchPathOrSegments(normalizedPath, filter) +} + +// matchPathOrSegments tries filepath.Match against the full path and each +// individual path segment, so "*.pdf" matches "sub/file.pdf" via the segment. +func matchPathOrSegments(path, pattern string) bool { + if matched, _ := filepath.Match(pattern, path); matched { + return true + } + for _, segment := range strings.Split(path, "/") { + if matched, _ := filepath.Match(pattern, segment); matched { return true } } diff --git a/pkg/obsidian/utils_test.go b/pkg/obsidian/utils_test.go index ec8e277..a83566b 100644 --- a/pkg/obsidian/utils_test.go +++ b/pkg/obsidian/utils_test.go @@ -173,6 +173,7 @@ func TestIsExcluded(t *testing.T) { filters []string want bool }{ + // Plain path prefix matching {"empty filters", "Archive/note.md", []string{}, false}, {"nil filters", "Archive/note.md", nil, false}, {"exact folder match", "Archive", []string{"Archive"}, true}, @@ -183,6 +184,17 @@ func TestIsExcluded(t *testing.T) { {"partial folder name does not match", "Archives/note.md", []string{"Archive"}, false}, {"exact file match", "Private/secret.md", []string{"Private/secret.md"}, true}, {"multiple filters, one matches", "Templates/t.md", []string{"Archive", "Templates"}, true}, + // Glob patterns + {"glob *.pdf matches file at root", "report.pdf", []string{"*.pdf"}, true}, + {"glob *.pdf matches file in subfolder", "docs/report.pdf", []string{"*.pdf"}, true}, + {"glob *.pdf does not match .md", "note.md", []string{"*.pdf"}, false}, + {"glob *.pdf matches deeply nested", "a/b/c/file.pdf", []string{"*.pdf"}, true}, + // Double-star patterns + {"**/drafts matches folder at root", "drafts", []string{"**/drafts"}, true}, + {"**/drafts matches nested folder", "a/b/drafts", []string{"**/drafts"}, true}, + {"**/drafts matches file inside", "a/drafts/note.md", []string{"**/drafts"}, true}, + {"**/*.pdf matches nested pdf", "docs/file.pdf", []string{"**/*.pdf"}, true}, + {"**/drafts does not match partial", "mydrafts", []string{"**/drafts"}, false}, } for _, tt := range tests {