diff --git a/README.md b/README.md index 68201fb..f6d3148 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,21 @@ obs_cd() { Then you can use `obs_cd` to navigate to the default vault directory within your terminal. +### List Vaults + +Lists all registered Obsidian vaults. Alias: `lv` + +```bash +# Lists all vaults (name and path) +notesmd-cli list-vaults + +# Outputs vaults as JSON +notesmd-cli list-vaults --json + +# Outputs only vault paths (useful for scripting) +notesmd-cli list-vaults --path-only +``` + ### Open Note Open given note name in Obsidian (or your default editor). Note can also be an absolute path from top level of vault. diff --git a/cmd/list_vaults.go b/cmd/list_vaults.go new file mode 100644 index 0000000..b229816 --- /dev/null +++ b/cmd/list_vaults.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "log" + "os" + "sort" + "text/tabwriter" + + "github.com/Yakitrak/notesmd-cli/pkg/obsidian" + "github.com/spf13/cobra" +) + +var listVaultsJSON bool +var listVaultsPathOnly bool + +var listVaultsCmd = &cobra.Command{ + Use: "list-vaults", + Aliases: []string{"lv"}, + Short: "lists all registered Obsidian vaults", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + vaults, err := obsidian.ListVaults() + if err != nil { + log.Fatal(err) + } + + sort.Slice(vaults, func(i, j int) bool { + return vaults[i].Name < vaults[j].Name + }) + + if listVaultsJSON { + output, err := json.MarshalIndent(vaults, "", " ") + if err != nil { + log.Fatal(err) + } + fmt.Println(string(output)) + return + } + + if listVaultsPathOnly { + for _, v := range vaults { + fmt.Println(v.Path) + } + } else { + formatVaultsTable(os.Stdout, vaults) + } + }, +} + +// formatVaultsTable writes vaults as aligned columns using tabwriter, +// so that the path column lines up regardless of vault name length. +// +// Example output: +// +// Notes /home/user/Notes +// LongVaultName /home/user/LongVaultName +// Work /home/user/Work +func formatVaultsTable(w io.Writer, vaults []obsidian.VaultInfo) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for _, v := range vaults { + fmt.Fprintf(tw, "%s\t%s\n", v.Name, v.Path) + } + tw.Flush() +} + +func init() { + listVaultsCmd.Flags().BoolVar(&listVaultsJSON, "json", false, "output as JSON array") + listVaultsCmd.Flags().BoolVar(&listVaultsPathOnly, "path-only", false, "output one path per line") + listVaultsCmd.MarkFlagsMutuallyExclusive("json", "path-only") + rootCmd.AddCommand(listVaultsCmd) +} diff --git a/cmd/list_vaults_test.go b/cmd/list_vaults_test.go new file mode 100644 index 0000000..4ee581a --- /dev/null +++ b/cmd/list_vaults_test.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/Yakitrak/notesmd-cli/pkg/obsidian" + "github.com/stretchr/testify/assert" +) + +func TestFormatVaultsTable(t *testing.T) { + t.Run("Aligns columns with varying name lengths", func(t *testing.T) { + vaults := []obsidian.VaultInfo{ + {Name: "Notes", Path: "/home/user/Notes"}, + {Name: "LongVaultName", Path: "/home/user/LongVaultName"}, + {Name: "Work", Path: "/home/user/Work"}, + } + + var buf bytes.Buffer + formatVaultsTable(&buf, vaults) + output := buf.String() + + // All path columns should start at the same position + lines := bytes.Split(bytes.TrimSpace([]byte(output)), []byte("\n")) + assert.Len(t, lines, 3) + + // Each line should contain both name and path + assert.Contains(t, output, "Notes") + assert.Contains(t, output, "/home/user/Notes") + assert.Contains(t, output, "LongVaultName") + assert.Contains(t, output, "/home/user/LongVaultName") + + // Paths should be aligned — find the byte offset of each path + // With tabwriter, the path column should start at the same position + pathOffsets := make([]int, len(lines)) + for i, line := range lines { + pathOffsets[i] = bytes.Index(line, []byte("/home")) + } + assert.Equal(t, pathOffsets[0], pathOffsets[1], "path columns should be aligned") + assert.Equal(t, pathOffsets[1], pathOffsets[2], "path columns should be aligned") + }) + + t.Run("Single vault produces output", func(t *testing.T) { + vaults := []obsidian.VaultInfo{ + {Name: "MyVault", Path: "/tmp/MyVault"}, + } + + var buf bytes.Buffer + formatVaultsTable(&buf, vaults) + output := buf.String() + + assert.Contains(t, output, "MyVault") + assert.Contains(t, output, "/tmp/MyVault") + }) + + t.Run("Empty vault list produces no output", func(t *testing.T) { + var buf bytes.Buffer + formatVaultsTable(&buf, []obsidian.VaultInfo{}) + + assert.Empty(t, buf.String()) + }) +} diff --git a/cmd/set_default.go b/cmd/set_default.go index a51b25d..cb41b64 100644 --- a/cmd/set_default.go +++ b/cmd/set_default.go @@ -25,7 +25,10 @@ var setDefaultCmd = &cobra.Command{ } if len(args) > 0 { - name := args[0] + name, err := obsidian.ResolveVaultName(args[0]) + if err != nil { + log.Fatal(err) + } v := obsidian.Vault{Name: name} if err := v.SetDefaultName(name); err != nil { log.Fatal(err) diff --git a/pkg/config/obsidian_path.go b/pkg/config/obsidian_path.go index 4bcaf13..17fff1c 100644 --- a/pkg/config/obsidian_path.go +++ b/pkg/config/obsidian_path.go @@ -60,6 +60,13 @@ func ObsidianFile() (obsidianConfigFile string, err error) { candidatePaths = append(candidatePaths, filepath.Join(homeDir, "snap", "obsidian", "current", ".config", "obsidian", ObsidianConfigFile)) + // Also check numbered snap subdirectories (e.g. ~/snap/obsidian/x1/.config/obsidian/) + // which exist when the "current" symlink is missing or multiple snap versions are installed. + snapNumberedPattern := filepath.Join(homeDir, "snap", "obsidian", "*", ".config", "obsidian", ObsidianConfigFile) + if matches, globErr := filepath.Glob(snapNumberedPattern); globErr == nil { + candidatePaths = append(candidatePaths, matches...) + } + var firstNonExistErr error for _, path := range candidatePaths { if _, statErr := os.Stat(path); statErr == nil { diff --git a/pkg/config/obsidian_path_test.go b/pkg/config/obsidian_path_test.go index 64ce6b5..1c740b3 100644 --- a/pkg/config/obsidian_path_test.go +++ b/pkg/config/obsidian_path_test.go @@ -201,6 +201,36 @@ func TestConfigObsidianPath(t *testing.T) { assert.Equal(t, nativeConfigFile, obsConfigFile) }) + t.Run("Finds numbered Snap config on Linux when current symlink does not exist", func(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Snap test only runs on Linux") + } + + originalUserConfigDir := config.UserConfigDirectory + defer func() { config.UserConfigDirectory = originalUserConfigDir }() + + tempDir := t.TempDir() + snapDir := filepath.Join(tempDir, "snap", "obsidian", "x1", ".config", "obsidian") + err := os.MkdirAll(snapDir, 0755) + assert.NoError(t, err) + + snapConfigFile := filepath.Join(snapDir, "obsidian.json") + err = os.WriteFile(snapConfigFile, []byte(`{"vaults":{}}`), 0644) + assert.NoError(t, err) + + origHome := os.Getenv("HOME") + defer os.Setenv("HOME", origHome) + os.Setenv("HOME", tempDir) + + config.UserConfigDirectory = func() (string, error) { + return filepath.Join(tempDir, ".config"), nil + } + + obsConfigFile, err := config.ObsidianFile() + assert.NoError(t, err) + assert.Equal(t, snapConfigFile, obsConfigFile) + }) + t.Run("Finds WSL Install Location", func(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("WSL test only runs on Linux") diff --git a/pkg/obsidian/vault_list.go b/pkg/obsidian/vault_list.go new file mode 100644 index 0000000..9cb4f12 --- /dev/null +++ b/pkg/obsidian/vault_list.go @@ -0,0 +1,96 @@ +package obsidian + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +type VaultInfo struct { + Name string `json:"name"` + Path string `json:"path"` +} + +func ListVaults() ([]VaultInfo, error) { + obsidianConfigFile, err := ObsidianConfigFile() + if err != nil { + return nil, err + } + + content, err := os.ReadFile(obsidianConfigFile) + if err != nil { + return nil, errors.New(ObsidianConfigReadError) + } + + vaultsContent := ObsidianVaultConfig{} + if json.Unmarshal(content, &vaultsContent) != nil { + return nil, errors.New(ObsidianConfigParseError) + } + + vaults := make([]VaultInfo, 0, len(vaultsContent.Vaults)) + for _, element := range vaultsContent.Vaults { + path := element.Path + if RunningInWSL() { + path = adjustForWslMount(path) + } + vaults = append(vaults, VaultInfo{ + Name: filepath.Base(path), + Path: path, + }) + } + + return vaults, nil +} + +// ResolveVaultName validates user input against registered Obsidian vaults. +// It accepts a vault name or a path and resolves it to the correct vault name. +func ResolveVaultName(input string) (string, error) { + vaults, err := ListVaults() + if err != nil { + return "", err + } + + if len(vaults) == 0 { + return "", errors.New("no vaults registered in Obsidian. Please create a vault in Obsidian first") + } + + // Collect all name matches + var nameMatches []VaultInfo + for _, v := range vaults { + if v.Name == input { + nameMatches = append(nameMatches, v) + } + } + if len(nameMatches) == 1 { + return nameMatches[0].Name, nil + } + if len(nameMatches) > 1 { + var paths []string + for _, m := range nameMatches { + paths = append(paths, fmt.Sprintf(" %s", m.Path)) + } + return "", fmt.Errorf( + "multiple vaults named %q found. Use the full path to disambiguate:\n%s", + input, strings.Join(paths, "\n"), + ) + } + + // Exact path match (user passed a full path) + cleanInput := filepath.Clean(input) + for _, v := range vaults { + if filepath.Clean(v.Path) == cleanInput { + return v.Name, nil + } + } + + // Build available vault list for the error message + var available []string + for _, v := range vaults { + available = append(available, fmt.Sprintf(" %s\t(%s)", v.Name, v.Path)) + } + + return "", fmt.Errorf("vault %q not found in Obsidian.\nAvailable vaults:\n%s", input, strings.Join(available, "\n")) +} diff --git a/pkg/obsidian/vault_list_test.go b/pkg/obsidian/vault_list_test.go new file mode 100644 index 0000000..07115d1 --- /dev/null +++ b/pkg/obsidian/vault_list_test.go @@ -0,0 +1,305 @@ +package obsidian_test + +import ( + "os" + "testing" + + "github.com/Yakitrak/notesmd-cli/mocks" + "github.com/Yakitrak/notesmd-cli/pkg/obsidian" + "github.com/stretchr/testify/assert" +) + +func TestListVaults(t *testing.T) { + originalObsidianConfigFile := obsidian.ObsidianConfigFile + originalRunningInWSL := obsidian.RunningInWSL + defer func() { + obsidian.ObsidianConfigFile = originalObsidianConfigFile + obsidian.RunningInWSL = originalRunningInWSL + }() + + // Default: not running in WSL + obsidian.RunningInWSL = func() bool { return false } + + t.Run("Lists all vaults with derived names", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + + obsidianConfig := `{ + "vaults": { + "abc123": { + "path": "/Users/user/Documents/Personal" + }, + "def456": { + "path": "/Users/user/Documents/Work" + }, + "ghi789": { + "path": "/Users/user/Documents/Projects/Notes" + } + } + }` + err := os.WriteFile(mockObsidianConfigFile, []byte(obsidianConfig), 0644) + assert.NoError(t, err) + + vaults, err := obsidian.ListVaults() + + assert.NoError(t, err) + assert.Len(t, vaults, 3) + + names := make(map[string]string) + for _, v := range vaults { + names[v.Name] = v.Path + } + assert.Equal(t, "/Users/user/Documents/Personal", names["Personal"]) + assert.Equal(t, "/Users/user/Documents/Work", names["Work"]) + assert.Equal(t, "/Users/user/Documents/Projects/Notes", names["Notes"]) + }) + + t.Run("Empty vaults map returns empty slice", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + + err := os.WriteFile(mockObsidianConfigFile, []byte(`{"vaults":{}}`), 0644) + assert.NoError(t, err) + + vaults, err := obsidian.ListVaults() + + assert.NoError(t, err) + assert.Empty(t, vaults) + }) + + t.Run("Config file locator error is propagated", func(t *testing.T) { + obsidian.ObsidianConfigFile = func() (string, error) { + return "", os.ErrNotExist + } + + _, err := obsidian.ListVaults() + + assert.Equal(t, os.ErrNotExist, err) + }) + + t.Run("Config file unreadable returns read error", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + err := os.WriteFile(mockObsidianConfigFile, []byte(``), 0000) + assert.NoError(t, err) + + _, err = obsidian.ListVaults() + + assert.Equal(t, obsidian.ObsidianConfigReadError, err.Error()) + }) + + t.Run("Invalid JSON returns parse error", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + err := os.WriteFile(mockObsidianConfigFile, []byte(`not valid json`), 0644) + assert.NoError(t, err) + + _, err = obsidian.ListVaults() + + assert.Equal(t, obsidian.ObsidianConfigParseError, err.Error()) + }) + + t.Run("WSL path adjustment converts Windows path and derives name", func(t *testing.T) { + obsidian.RunningInWSL = func() bool { return true } + defer func() { obsidian.RunningInWSL = func() bool { return false } }() + + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + + configContent := `{ + "vaults": { + "abc123": { + "path": "C:\\Users\\user\\Documents\\MyVault" + } + } + }` + err := os.WriteFile(mockObsidianConfigFile, []byte(configContent), 0644) + assert.NoError(t, err) + + vaults, err := obsidian.ListVaults() + + assert.NoError(t, err) + assert.Len(t, vaults, 1) + assert.Equal(t, "MyVault", vaults[0].Name) + assert.Equal(t, "/mnt/c/Users/user/Documents/MyVault", vaults[0].Path) + }) + + t.Run("Single vault returns one entry", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + + configContent := `{ + "vaults": { + "abc123": { + "path": "/home/user/Notes" + } + } + }` + err := os.WriteFile(mockObsidianConfigFile, []byte(configContent), 0644) + assert.NoError(t, err) + + vaults, err := obsidian.ListVaults() + + assert.NoError(t, err) + assert.Len(t, vaults, 1) + assert.Equal(t, "Notes", vaults[0].Name) + assert.Equal(t, "/home/user/Notes", vaults[0].Path) + }) +} + +func TestResolveVaultName(t *testing.T) { + originalObsidianConfigFile := obsidian.ObsidianConfigFile + originalRunningInWSL := obsidian.RunningInWSL + defer func() { + obsidian.ObsidianConfigFile = originalObsidianConfigFile + obsidian.RunningInWSL = originalRunningInWSL + }() + + obsidian.RunningInWSL = func() bool { return false } + + obsidianConfig := `{ + "vaults": { + "abc123": { + "path": "/Users/user/Documents/Personal" + }, + "def456": { + "path": "/Users/user/Documents/Work" + } + } + }` + + setupConfig := func(t *testing.T) { + t.Helper() + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + err := os.WriteFile(mockObsidianConfigFile, []byte(obsidianConfig), 0644) + assert.NoError(t, err) + } + + t.Run("Resolves exact vault name", func(t *testing.T) { + setupConfig(t) + + name, err := obsidian.ResolveVaultName("Personal") + + assert.NoError(t, err) + assert.Equal(t, "Personal", name) + }) + + t.Run("Resolves full path to vault name", func(t *testing.T) { + setupConfig(t) + + name, err := obsidian.ResolveVaultName("/Users/user/Documents/Personal") + + assert.NoError(t, err) + assert.Equal(t, "Personal", name) + }) + + t.Run("Resolves path with trailing slash to vault name", func(t *testing.T) { + setupConfig(t) + + name, err := obsidian.ResolveVaultName("/Users/user/Documents/Work/") + + assert.NoError(t, err) + assert.Equal(t, "Work", name) + }) + + t.Run("Returns error for unregistered vault", func(t *testing.T) { + setupConfig(t) + + _, err := obsidian.ResolveVaultName("NonExistent") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found in Obsidian") + assert.Contains(t, err.Error(), "Personal") + assert.Contains(t, err.Error(), "Work") + }) + + t.Run("Returns error for unregistered path", func(t *testing.T) { + setupConfig(t) + + _, err := obsidian.ResolveVaultName("/var/workspace/obsidian") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found in Obsidian") + }) + + t.Run("Returns error when no vaults registered", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + err := os.WriteFile(mockObsidianConfigFile, []byte(`{"vaults":{}}`), 0644) + assert.NoError(t, err) + + _, err = obsidian.ResolveVaultName("anything") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no vaults registered") + }) + + t.Run("Returns error for ambiguous vault name", func(t *testing.T) { + ambiguousConfig := `{ + "vaults": { + "abc123": {"path": "/home/user/work/Notes"}, + "def456": {"path": "/home/user/personal/Notes"} + } + }` + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + err := os.WriteFile(mockObsidianConfigFile, []byte(ambiguousConfig), 0644) + assert.NoError(t, err) + + _, err = obsidian.ResolveVaultName("Notes") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "multiple vaults named") + assert.Contains(t, err.Error(), "/home/user/work/Notes") + assert.Contains(t, err.Error(), "/home/user/personal/Notes") + }) + + t.Run("Resolves ambiguous name via full path", func(t *testing.T) { + ambiguousConfig := `{ + "vaults": { + "abc123": {"path": "/home/user/work/Notes"}, + "def456": {"path": "/home/user/personal/Notes"} + } + }` + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + err := os.WriteFile(mockObsidianConfigFile, []byte(ambiguousConfig), 0644) + assert.NoError(t, err) + + name, err := obsidian.ResolveVaultName("/home/user/work/Notes") + + assert.NoError(t, err) + assert.Equal(t, "Notes", name) + }) + + t.Run("Propagates config file errors", func(t *testing.T) { + obsidian.ObsidianConfigFile = func() (string, error) { + return "", os.ErrNotExist + } + + _, err := obsidian.ResolveVaultName("Personal") + + assert.Error(t, err) + }) +}