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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 19 additions & 2 deletions pkg/obsidian/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
Expand Down
54 changes: 54 additions & 0 deletions pkg/obsidian/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 33 additions & 12 deletions pkg/obsidian/note.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -199,19 +206,25 @@ 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)

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, 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

Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
81 changes: 81 additions & 0 deletions pkg/obsidian/note_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
47 changes: 47 additions & 0 deletions pkg/obsidian/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,53 @@ 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.
// 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 {
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
}
}
return false
}

func ShouldSkipDirectoryOrFile(info os.FileInfo) bool {
isDirectory := info.IsDir()
isHidden := info.Name()[0] == '.'
Expand Down
Loading