diff --git a/README.md b/README.md index d1f4088..8135168 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,40 @@ # NotesMD CLI -> **Note**: With the release of the official Obsidian CLI., this project has been renamed from "Obsidian CLI" to "NotesMD CLI" to avoid confusion. NotesMD CLI works **without requiring Obsidian to be running**, making it perfect for scripting, automation, and terminal-only environments. +> **Note**: With the release of the official Obsidian CLI, this project has been renamed from "Obsidian CLI" to "NotesMD CLI" to avoid confusion. NotesMD CLI works **without requiring Obsidian to be running**, making it perfect for scripting, automation, and terminal-only environments. + +--- + +## Table of Contents + +- [Description](#description) +- [Install](#install) + - [Windows](#windows) + - [Mac and Linux](#mac-and-linux) + - [Arch Linux (AUR)](#arch-linux-aur) + - [Build from Source](#build-from-source) + - [Headless / No Obsidian Installed](#headless--no-obsidian-installed) +- [Migrating from Obsidian CLI](#migrating-from-obsidian-cli) +- [Usage](#usage) + - [Help](#help) + - [Editor Flag](#editor-flag) + - [Add Vault](#add-vault) + - [Remove Vault](#remove-vault) + - [List Vaults](#list-vaults) + - [Set Default Vault and Open Type](#set-default-vault-and-open-type) + - [Open Note](#open-note) + - [Daily Note](#daily-note) + - [Search Note](#search-note) + - [Search Note Content](#search-note-content) + - [List Vault Contents](#list-vault-contents) + - [Print Note](#print-note) + - [Create / Update Note](#create--update-note) + - [Move / Rename Note](#move--rename-note) + - [Delete Note](#delete-note) + - [Frontmatter](#frontmatter) +- [Deprecated Commands](#deprecated-commands) +- [Excluded Files](#excluded-files) +- [Contribution](#contribution) +- [License](#license) --- @@ -62,12 +96,35 @@ go build -o notesmd-cli . sudo install -m 755 notesmd-cli /usr/local/bin/ ``` -### Headless / No Obsidian Installed +### Headless / No Obsidian Installed -If you're running on a headless server or don't have Obsidian installed (e.g., server environments, containers, or systems without a GUI), you can still use this CLI. Obsidian requires a GUI, so this section explains how to set up the required configuration manually. +If you're running on a headless server or don't have Obsidian installed (e.g., server environments, containers, or systems without a GUI), you can still use this CLI. When Obsidian is installed, it registers vaults automatically. For headless environments, you register them via the CLI instead. **Setup Instructions:** +```bash +# Register your vault directory +notesmd-cli add-vault /home/user/vaults/my-brain + +# Set it as default +notesmd-cli set-default-vault "my-brain" + +# Or do both in one step +notesmd-cli add-vault /home/user/vaults/my-brain --set-default +``` + +For multiple vaults: +```bash +notesmd-cli add-vault /home/user/vaults/personal +notesmd-cli add-vault /home/user/vaults/work +notesmd-cli set-default-vault "personal" +``` + +You can then pass `--vault "work"` to target a specific vault. + +
+Manual setup (without CLI commands) + 1. Create the Obsidian config directory: ```bash mkdir -p ~/.config/obsidian @@ -83,22 +140,9 @@ If you're running on a headless server or don't have Obsidian installed (e.g., s } } ``` - The key (`any-unique-id`) can be anything — the CLI uses the **directory name** as the vault name (e.g., `my-brain` above). Use the **absolute path** — do not use `~` as the CLI does not expand it to your home directory. + The key (`any-unique-id`) can be anything. The CLI uses the **directory name** as the vault name (e.g., `my-brain` above). Use the **absolute path**. Do not use `~` as the CLI does not expand it to your home directory. - **Multiple vaults:** - ```json - { - "vaults": { - "vault-1": { - "path": "/home/user/vaults/personal" - }, - "vault-2": { - "path": "/home/user/vaults/work" - } - } - } - ``` - You can then use `notesmd-cli set-default "personal"` or pass `--vault "work"` to target a specific vault. +
--- @@ -145,66 +189,86 @@ notesmd-cli move "old.md" "new.md" --open --editor To avoid passing `--editor` every time, configure it as the default open type once: ```bash -notesmd-cli set-default --open-type editor +notesmd-cli set-default-vault --open-type editor ``` -### Set Default Vault and Open Type +### Add Vault -Defines the default vault and/or open type for future usage. If no default vault is set, pass `--vault` with other commands to specify which vault to use. +Registers a directory as an Obsidian vault. Creates the Obsidian config file (`~/.config/obsidian/obsidian.json`) if it does not exist. Alias: `av` -```bash -# Set default vault (vault name only, not the path) -notesmd-cli set-default "{vault-name}" +If you have Obsidian installed, vaults are registered automatically when you open them. You only need this command for headless setups or environments where Obsidian is not installed (servers, containers, CI). -# Set default open type: 'obsidian' (default) or 'editor' -notesmd-cli set-default --open-type editor +```bash +# Register a vault +notesmd-cli add-vault /path/to/vault -# Set both at once -notesmd-cli set-default "{vault-name}" --open-type editor +# Register and set as default +notesmd-cli add-vault /path/to/vault --set-default ``` -When `default_open_type` is set to `editor`, commands that support `--open` will open notes in `$EDITOR` automatically, without needing to pass `--editor` each time. +### Remove Vault -Note: `open` and other commands in `notesmd-cli` use this vault's base directory as the working directory, not the current working directory of your terminal. +Removes a vault from the Obsidian config. Does not delete any files on disk. If the removed vault was the default, the default is cleared. Alias: `rv` -### Print Default Vault +```bash +# Remove by vault name +notesmd-cli remove-vault "{vault-name}" -Prints default vault and path. Please set this with `set-default` command if not set. +# Remove by vault path +notesmd-cli remove-vault /path/to/vault +``` + +### List Vaults + +Lists all registered Obsidian vaults. The default vault is marked with `(default)`. Alias: `lv` ```bash -# print the default vault name and path -notesmd-cli print-default +# Lists all vaults (name and path, default marked) +notesmd-cli list-vaults -# print only the vault path -notesmd-cli print-default --path-only +# Outputs vaults as JSON +notesmd-cli list-vaults --json + +# Outputs only vault paths (useful for scripting) +notesmd-cli list-vaults --path-only + +# Show only the default vault (name, path, open type) +notesmd-cli list-vaults --default + +# Get just the default vault path (useful for scripting) +notesmd-cli list-vaults --default --path-only ``` You can add this to your shell configuration file (like `~/.zshrc`) to quickly navigate to the default vault: ```bash obs_cd() { - local result=$(notesmd-cli print-default --path-only) + local result=$(notesmd-cli list-vaults --default --path-only) [ -n "$result" ] && cd -- "$result" } ``` Then you can use `obs_cd` to navigate to the default vault directory within your terminal. -### List Vaults +### Set Default Vault and Open Type -Lists all registered Obsidian vaults. Alias: `lv` +Defines the default vault and/or open type for future usage. If no default vault is set, pass `--vault` with other commands to specify which vault to use. ```bash -# Lists all vaults (name and path) -notesmd-cli list-vaults +# Set default vault (by name or path) +notesmd-cli set-default-vault "{vault-name}" -# Outputs vaults as JSON -notesmd-cli list-vaults --json +# Set default open type: 'obsidian' (default) or 'editor' +notesmd-cli set-default-vault --open-type editor -# Outputs only vault paths (useful for scripting) -notesmd-cli list-vaults --path-only +# Set both at once +notesmd-cli set-default-vault "{vault-name}" --open-type editor ``` +When `default_open_type` is set to `editor`, commands that support `--open` will open notes in `$EDITOR` automatically, without needing to pass `--editor` each time. + +Note: `open` and other commands in `notesmd-cli` use this vault's base directory as the working directory, not the current working directory of your terminal. + ### Open Note Open given note name in Obsidian (or your default editor). Note can also be an absolute path from top level of vault. @@ -227,7 +291,7 @@ notesmd-cli open "{note-name}" --editor ### Daily Note -Creates or opens today's daily note directly on disk — **Obsidian does not need to be running**. If `.obsidian/daily-notes.json` exists in the vault, the CLI reads `folder`, `format` (Moment.js date format, default `YYYY-MM-DD`), and `template` from it. A template file's content is used when creating a new daily note. If the config is missing or unreadable, defaults are used (vault root, `YYYY-MM-DD`, no template). +Creates or opens today's daily note directly on disk. **Obsidian does not need to be running**. If `.obsidian/daily-notes.json` exists in the vault, the CLI reads `folder`, `format` (Moment.js date format, default `YYYY-MM-DD`), and `template` from it. A template file's content is used when creating a new daily note. If the config is missing or unreadable, defaults are used (vault root, `YYYY-MM-DD`, no template). ```bash # Creates / opens daily note in obsidian vault @@ -318,7 +382,7 @@ notesmd-cli print "{note-name}" --vault "{vault-name}" ### Create / Update Note -Creates a note (can also be a path with name) directly on disk — **Obsidian does not need to be running**. If the note already exists and neither `--overwrite` nor `--append` is passed, the file is left unchanged. Intermediate directories are created automatically. +Creates a note (can also be a path with name) directly on disk. **Obsidian does not need to be running**. If the note already exists and neither `--overwrite` nor `--append` is passed, the file is left unchanged. Intermediate directories are created automatically. When the note name has no explicit path (no `/`), the CLI reads `.obsidian/app.json` from the vault to check for a configured default folder (`newFileLocation: "folder"` and `newFileFolderPath`). If configured, the note is placed in that folder. If the config is missing or unreadable, the note is created at the vault root. @@ -369,10 +433,10 @@ notesmd-cli move "{current-note-path}" "{new-note-path}" --open --editor Deletes a given note (path from top level of vault). ```bash -# Renames a note in default obsidian +# Deletes a note in default vault notesmd-cli delete "{note-path}" -# Renames a note in given obsidian +# Deletes a note in specified vault notesmd-cli delete "{note-path}" --vault "{vault-name}" ``` @@ -394,12 +458,22 @@ notesmd-cli frontmatter "{note-name}" --delete --key "draft" notesmd-cli frontmatter "{note-name}" --print --vault "{vault-name}" ``` +## Deprecated Commands + +The following commands still work but print a deprecation warning to stderr (so pipes and scripts are unaffected). They will be removed in the next major version. + +| Old command | Replacement | +|---|---| +| `set-default` | `set-default-vault` | +| `print-default` | `list-vaults --default` | +| `print-default --path-only` | `list-vaults --default --path-only` | + ## Excluded Files -The CLI respects Obsidian's **Excluded Files** setting (`Settings → Files & Links → 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 +- `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. diff --git a/cmd/add_vault.go b/cmd/add_vault.go new file mode 100644 index 0000000..1bd59be --- /dev/null +++ b/cmd/add_vault.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + "log" + "path/filepath" + + "github.com/Yakitrak/notesmd-cli/pkg/obsidian" + "github.com/spf13/cobra" +) + +var addVaultCmd = &cobra.Command{ + Use: "add-vault ", + Aliases: []string{"av"}, + Short: "Register a vault directory", + Long: "Registers a directory as an Obsidian vault. Creates the Obsidian config file if it does not exist.", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + absPath, err := obsidian.AddVault(args[0]) + if err != nil { + log.Fatal(err) + } + + name := filepath.Base(absPath) + fmt.Printf("Vault %q registered at: %s\n", name, absPath) + + setDefault, _ := cmd.Flags().GetBool("set-default") + if setDefault { + v := obsidian.Vault{Name: name} + if err := v.SetDefaultName(name); err != nil { + log.Fatal(err) + } + fmt.Println("Default vault set to:", name) + } + }, +} + +func init() { + addVaultCmd.Flags().Bool("set-default", false, "set the added vault as the default") + rootCmd.AddCommand(addVaultCmd) +} diff --git a/cmd/list_vaults.go b/cmd/list_vaults.go index 785e561..fb957b8 100644 --- a/cmd/list_vaults.go +++ b/cmd/list_vaults.go @@ -15,6 +15,7 @@ import ( var listVaultsJSON bool var listVaultsPathOnly bool +var listVaultsDefault bool var listVaultsCmd = &cobra.Command{ Use: "list-vaults", @@ -27,6 +28,18 @@ var listVaultsCmd = &cobra.Command{ log.Fatal(err) } + defaultName := resolveDefaultVaultName() + + if listVaultsDefault { + runListVaultsDefault(vaults, defaultName) + return + } + + if len(vaults) == 0 { + fmt.Println("No vaults registered. Use add-vault to register one.") + return + } + sort.Slice(vaults, func(i, j int) bool { return vaults[i].Name < vaults[j].Name }) @@ -45,30 +58,86 @@ var listVaultsCmd = &cobra.Command{ fmt.Println(v.Path) } } else { - formatVaultsTable(os.Stdout, vaults) + formatVaultsTable(os.Stdout, vaults, defaultName) } }, } +func runListVaultsDefault(vaults []obsidian.VaultInfo, defaultName string) { + if defaultName == "" { + fmt.Println("No default vault set. Use set-default-vault to set one.") + return + } + + var defaultVault *obsidian.VaultInfo + for _, v := range vaults { + if v.Name == defaultName { + defaultVault = &v + break + } + } + + if defaultVault == nil { + fmt.Printf("Default vault %q is set but not found in registered vaults.\n", defaultName) + return + } + + if listVaultsJSON { + output, err := json.MarshalIndent(defaultVault, "", " ") + if err != nil { + log.Fatal(err) + } + fmt.Println(string(output)) + return + } + + if listVaultsPathOnly { + fmt.Println(defaultVault.Path) + return + } + + vault := obsidian.Vault{Name: defaultName} + openType, _ := vault.DefaultOpenType() + + fmt.Println("Default vault name:", defaultVault.Name) + fmt.Println("Default vault path:", defaultVault.Path) + fmt.Println("Default open type:", openType) +} + // formatVaultsTable writes vaults as aligned columns using tabwriter, // so that the path column lines up regardless of vault name length. +// The default vault is marked with (default). // // Example output: // -// Notes /home/user/Notes +// Notes /home/user/Notes (default) // LongVaultName /home/user/LongVaultName // Work /home/user/Work -func formatVaultsTable(w io.Writer, vaults []obsidian.VaultInfo) { +func formatVaultsTable(w io.Writer, vaults []obsidian.VaultInfo, defaultName string) { tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) for _, v := range vaults { - _, _ = fmt.Fprintf(tw, "%s\t%s\n", v.Name, v.Path) + if v.Name == defaultName { + _, _ = fmt.Fprintf(tw, "%s\t%s\t(default)\n", v.Name, v.Path) + } else { + _, _ = fmt.Fprintf(tw, "%s\t%s\n", v.Name, v.Path) + } } _ = tw.Flush() } +func resolveDefaultVaultName() string { + vault := obsidian.Vault{} + name, err := vault.DefaultName() + if err != nil { + return "" + } + return name +} + 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.Flags().BoolVar(&listVaultsDefault, "default", false, "show only the default vault") listVaultsCmd.MarkFlagsMutuallyExclusive("json", "path-only") rootCmd.AddCommand(listVaultsCmd) } diff --git a/cmd/list_vaults_test.go b/cmd/list_vaults_test.go index 4ee581a..8527881 100644 --- a/cmd/list_vaults_test.go +++ b/cmd/list_vaults_test.go @@ -17,7 +17,7 @@ func TestFormatVaultsTable(t *testing.T) { } var buf bytes.Buffer - formatVaultsTable(&buf, vaults) + formatVaultsTable(&buf, vaults, "") output := buf.String() // All path columns should start at the same position @@ -46,16 +46,35 @@ func TestFormatVaultsTable(t *testing.T) { } var buf bytes.Buffer - formatVaultsTable(&buf, vaults) + formatVaultsTable(&buf, vaults, "") output := buf.String() assert.Contains(t, output, "MyVault") assert.Contains(t, output, "/tmp/MyVault") }) + t.Run("Marks default vault", func(t *testing.T) { + vaults := []obsidian.VaultInfo{ + {Name: "Notes", Path: "/home/user/Notes"}, + {Name: "Work", Path: "/home/user/Work"}, + } + + var buf bytes.Buffer + formatVaultsTable(&buf, vaults, "Work") + output := buf.String() + + assert.Contains(t, output, "Work") + assert.Contains(t, output, "(default)") + // Notes line should not have (default) + lines := bytes.Split(bytes.TrimSpace([]byte(output)), []byte("\n")) + assert.Len(t, lines, 2) + assert.NotContains(t, string(lines[0]), "(default)") + assert.Contains(t, string(lines[1]), "(default)") + }) + t.Run("Empty vault list produces no output", func(t *testing.T) { var buf bytes.Buffer - formatVaultsTable(&buf, []obsidian.VaultInfo{}) + formatVaultsTable(&buf, []obsidian.VaultInfo{}, "") assert.Empty(t, buf.String()) }) diff --git a/cmd/print_default.go b/cmd/print_default.go index 72b003d..6b856dd 100644 --- a/cmd/print_default.go +++ b/cmd/print_default.go @@ -8,13 +8,15 @@ import ( "github.com/spf13/cobra" ) -var printPathOnly bool -var printDefaultCmd = &cobra.Command{ - Use: "print-default", - Aliases: []string{"pd"}, - Short: "prints default vault name and path", - Args: cobra.ExactArgs(0), +var printDefaultDeprecatedCmd = &cobra.Command{ + Use: "print-default", + Aliases: []string{"pd"}, + Short: "prints default vault name and path (deprecated: use list-vaults --default)", + Args: cobra.ExactArgs(0), + Deprecated: "use list-vaults --default instead", Run: func(cmd *cobra.Command, args []string) { + pathOnly, _ := cmd.Flags().GetBool("path-only") + vault := obsidian.Vault{} name, err := vault.DefaultName() if err != nil { @@ -25,7 +27,7 @@ var printDefaultCmd = &cobra.Command{ log.Fatal(err) } - if printPathOnly { + if pathOnly { fmt.Print(path) return } @@ -39,6 +41,6 @@ var printDefaultCmd = &cobra.Command{ } func init() { - printDefaultCmd.Flags().BoolVar(&printPathOnly, "path-only", false, "print only the vault path") - rootCmd.AddCommand(printDefaultCmd) + printDefaultDeprecatedCmd.Flags().Bool("path-only", false, "print only the vault path") + rootCmd.AddCommand(printDefaultDeprecatedCmd) } diff --git a/cmd/remove_vault.go b/cmd/remove_vault.go new file mode 100644 index 0000000..1c2225c --- /dev/null +++ b/cmd/remove_vault.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "fmt" + "log" + "os" + + "github.com/Yakitrak/notesmd-cli/pkg/obsidian" + "github.com/spf13/cobra" +) + +var removeVaultCmd = &cobra.Command{ + Use: "remove-vault ", + Aliases: []string{"rv"}, + Short: "Unregister a vault", + Long: "Removes a vault from the Obsidian config. Does not delete any files on disk.", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + input := args[0] + + name, err := obsidian.RemoveVault(input) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Vault %q removed\n", name) + + if err := obsidian.ClearDefaultIfMatch(name); err != nil { + fmt.Fprintln(os.Stderr, "Warning: could not clear default vault:", err) + } + }, +} + +func init() { + rootCmd.AddCommand(removeVaultCmd) +} diff --git a/cmd/set_default.go b/cmd/set_default.go index cb41b64..a9ffd21 100644 --- a/cmd/set_default.go +++ b/cmd/set_default.go @@ -9,55 +9,67 @@ import ( "github.com/spf13/cobra" ) -var setDefaultCmd = &cobra.Command{ - Use: "set-default", - Aliases: []string{"sd"}, - Short: "Sets default vault and/or open type", - Args: cobra.RangeArgs(0, 1), - Run: func(cmd *cobra.Command, args []string) { - openType, err := cmd.Flags().GetString("open-type") +func runSetDefaultVault(cmd *cobra.Command, args []string) { + openType, err := cmd.Flags().GetString("open-type") + if err != nil { + log.Fatalf("Failed to parse --open-type flag: %v", err) + } + + if len(args) == 0 && openType == "" { + log.Fatal("Please provide a vault name or use --open-type to set the default open type") + } + + if len(args) > 0 { + name, err := obsidian.ResolveVaultName(args[0]) if err != nil { - log.Fatalf("Failed to parse --open-type flag: %v", err) + log.Fatal(err) } - - if len(args) == 0 && openType == "" { - log.Fatal("Please provide a vault name or use --open-type to set the default open type") + v := obsidian.Vault{Name: name} + if err := v.SetDefaultName(name); err != nil { + log.Fatal(err) } - - if len(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) - } - fmt.Println("Default vault set to:", name) - path, err := v.Path() - if err != nil { - // Path resolution is best-effort: the name is saved; Obsidian's - // config file may not be present or may not contain this vault yet. - fmt.Fprintln(os.Stderr, "Note: could not resolve vault path:", err) - } else { - fmt.Println("Default vault path set to:", path) - } + fmt.Println("Default vault set to:", name) + path, err := v.Path() + if err != nil { + // Path resolution is best-effort: the name is saved; Obsidian's + // config file may not be present or may not contain this vault yet. + fmt.Fprintln(os.Stderr, "Note: could not resolve vault path:", err) + } else { + fmt.Println("Default vault path set to:", path) } + } - if openType != "" { - if openType != "obsidian" && openType != "editor" { - log.Fatalf("Invalid open type %q: must be 'obsidian' or 'editor'", openType) - } - v := obsidian.Vault{} - if err := v.SetDefaultOpenType(openType); err != nil { - log.Fatal(err) - } - fmt.Println("Default open type set to:", openType) + if openType != "" { + if openType != "obsidian" && openType != "editor" { + log.Fatalf("Invalid open type %q: must be 'obsidian' or 'editor'", openType) } - }, + v := obsidian.Vault{} + if err := v.SetDefaultOpenType(openType); err != nil { + log.Fatal(err) + } + fmt.Println("Default open type set to:", openType) + } +} + +var setDefaultVaultCmd = &cobra.Command{ + Use: "set-default-vault", + Aliases: []string{"sd"}, + Short: "Sets default vault and/or open type", + Args: cobra.RangeArgs(0, 1), + Run: runSetDefaultVault, +} + +var setDefaultDeprecatedCmd = &cobra.Command{ + Use: "set-default", + Short: "Sets default vault and/or open type (deprecated: use set-default-vault)", + Args: cobra.RangeArgs(0, 1), + Deprecated: "use set-default-vault instead", + Run: runSetDefaultVault, } func init() { - setDefaultCmd.Flags().String("open-type", "", "default open type: 'obsidian' (default) or 'editor'") - rootCmd.AddCommand(setDefaultCmd) + setDefaultVaultCmd.Flags().String("open-type", "", "default open type: 'obsidian' (default) or 'editor'") + setDefaultDeprecatedCmd.Flags().String("open-type", "", "default open type: 'obsidian' (default) or 'editor'") + rootCmd.AddCommand(setDefaultVaultCmd) + rootCmd.AddCommand(setDefaultDeprecatedCmd) } diff --git a/pkg/obsidian/constants.go b/pkg/obsidian/constants.go index 65c9c24..56e5804 100644 --- a/pkg/obsidian/constants.go +++ b/pkg/obsidian/constants.go @@ -6,8 +6,8 @@ const ( VaultAccessError = "Failed to access vault directory" VaultReadError = "Failed to read notes in vault" VaultWriteError = "Failed to write to update notes in vault" - ObsidianCLIConfigReadError = "Cannot find vault config, please use set-default command to set default vault or use --vault flag" - ObsidianCLIConfigParseError = "Could not parse vault config file, please use set-default command to set default vault or use --vault flag" + ObsidianCLIConfigReadError = "Cannot find vault config, please use set-default-vault command to set default vault or use --vault flag" + ObsidianCLIConfigParseError = "Could not parse vault config file, please use set-default-vault command to set default vault or use --vault flag" ObsidianCLIConfigDirWriteEror = "Failed to create vault config directory. Please ensure you have the correct permissions." ObsidianCLIConfigGenerateJSONError = "Failed to generate vault config file. Please ensure vault name does not contain any special characters." ObsidianCLIConfigWriteError = "Failed to write vault config file. Please ensure you have correct permissions." diff --git a/pkg/obsidian/vault_registry.go b/pkg/obsidian/vault_registry.go new file mode 100644 index 0000000..457a993 --- /dev/null +++ b/pkg/obsidian/vault_registry.go @@ -0,0 +1,202 @@ +package obsidian + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Yakitrak/notesmd-cli/pkg/config" +) + +// AddVault registers a vault path in the Obsidian config file. +// It creates the config file and directory if they don't exist. +// Returns the resolved absolute path on success. +func AddVault(vaultPath string) (string, error) { + absPath, err := filepath.Abs(vaultPath) + if err != nil { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + + info, err := os.Stat(absPath) + if err != nil { + return "", fmt.Errorf("path does not exist: %s", absPath) + } + if !info.IsDir() { + return "", fmt.Errorf("path is not a directory: %s", absPath) + } + + obsidianConfigFile, vaultsConfig, err := readOrCreateObsidianConfig() + if err != nil { + return "", err + } + + // Check if vault already registered + for _, v := range vaultsConfig.Vaults { + if filepath.Clean(v.Path) == filepath.Clean(absPath) { + return "", fmt.Errorf("vault already registered: %s", absPath) + } + } + + id, err := generateVaultID() + if err != nil { + return "", fmt.Errorf("failed to generate vault ID: %w", err) + } + + vaultsConfig.Vaults[id] = struct { + Path string `json:"path"` + }{Path: absPath} + + return absPath, writeObsidianConfig(obsidianConfigFile, vaultsConfig) +} + +// RemoveVault removes a vault from the Obsidian config file by name or path. +// Returns the resolved vault name (directory basename) so callers can use it +// for follow-up operations like clearing the default. +func RemoveVault(input string) (string, error) { + obsidianConfigFile, err := ObsidianConfigFile() + if err != nil { + return "", errors.New(ObsidianConfigReadError) + } + + content, err := os.ReadFile(obsidianConfigFile) + if err != nil { + return "", errors.New(ObsidianConfigReadError) + } + + vaultsConfig := ObsidianVaultConfig{} + if json.Unmarshal(content, &vaultsConfig) != nil { + return "", errors.New(ObsidianConfigParseError) + } + + // Exact path match (only when input looks like a path, not a bare name) + if filepath.IsAbs(input) || strings.Contains(input, string(filepath.Separator)) || strings.HasPrefix(input, ".") { + absInput, _ := filepath.Abs(input) + for id, v := range vaultsConfig.Vaults { + if filepath.Clean(v.Path) == filepath.Clean(absInput) { + name := filepath.Base(v.Path) + delete(vaultsConfig.Vaults, id) + return name, writeObsidianConfig(obsidianConfigFile, vaultsConfig) + } + } + } + + // Name match -- collect all matches to detect ambiguity + type match struct { + id string + path string + } + var matches []match + for id, v := range vaultsConfig.Vaults { + if filepath.Base(v.Path) == input { + matches = append(matches, match{id: id, path: v.Path}) + } + } + + if len(matches) == 0 { + return "", fmt.Errorf("vault %q not found", input) + } + if len(matches) > 1 { + var paths []string + for _, m := range matches { + 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"), + ) + } + + delete(vaultsConfig.Vaults, matches[0].id) + return input, writeObsidianConfig(obsidianConfigFile, vaultsConfig) +} + +// ClearDefaultIfMatch clears the default vault in CLI config if it matches the given name. +func ClearDefaultIfMatch(name string) error { + _, cliConfigFile, err := CliConfigPath() + if err != nil { + return nil //nolint:nilerr // no config dir means nothing to clear + } + + content, err := os.ReadFile(cliConfigFile) + if err != nil { + return nil //nolint:nilerr // no config file means nothing to clear + } + + cliConfig := CliConfig{} + if err := json.Unmarshal(content, &cliConfig); err != nil { + return nil //nolint:nilerr // unparseable config means nothing to clear + } + + if cliConfig.DefaultVaultName != name { + return nil + } + + v := &Vault{} + return v.SetDefaultName("") +} + +func readOrCreateObsidianConfig() (string, ObsidianVaultConfig, error) { + empty := ObsidianVaultConfig{ + Vaults: make(map[string]struct { + Path string `json:"path"` + }), + } + + // Try to find existing config + obsidianConfigFile, err := ObsidianConfigFile() + if err == nil { + content, readErr := os.ReadFile(obsidianConfigFile) + if readErr == nil { + vaultsConfig := ObsidianVaultConfig{} + if err := json.Unmarshal(content, &vaultsConfig); err != nil { + return "", empty, fmt.Errorf("corrupt obsidian config at %s: %w", obsidianConfigFile, err) + } + if vaultsConfig.Vaults == nil { + vaultsConfig.Vaults = make(map[string]struct { + Path string `json:"path"` + }) + } + return obsidianConfigFile, vaultsConfig, nil + } + } + + // Config doesn't exist, create it + userConfigDir, err := config.UserConfigDirectory() + if err != nil { + return "", empty, fmt.Errorf("failed to determine config directory: %w", err) + } + + configDir := filepath.Join(userConfigDir, config.ObsidianConfigDirectory) + if err := os.MkdirAll(configDir, os.ModePerm); err != nil { + return "", empty, fmt.Errorf("failed to create config directory: %w", err) + } + + configFile := filepath.Join(configDir, config.ObsidianConfigFile) + return configFile, empty, nil +} + +func writeObsidianConfig(path string, cfg ObsidianVaultConfig) error { + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} + +func generateVaultID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/pkg/obsidian/vault_registry_test.go b/pkg/obsidian/vault_registry_test.go new file mode 100644 index 0000000..ccbc97d --- /dev/null +++ b/pkg/obsidian/vault_registry_test.go @@ -0,0 +1,222 @@ +package obsidian_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/Yakitrak/notesmd-cli/mocks" + "github.com/Yakitrak/notesmd-cli/pkg/obsidian" + "github.com/stretchr/testify/assert" +) + +func TestAddVault(t *testing.T) { + originalObsidianConfigFile := obsidian.ObsidianConfigFile + originalRunningInWSL := obsidian.RunningInWSL + defer func() { + obsidian.ObsidianConfigFile = originalObsidianConfigFile + obsidian.RunningInWSL = originalRunningInWSL + }() + + obsidian.RunningInWSL = func() bool { return false } + + t.Run("Adds vault to existing config", 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) + + vaultDir := t.TempDir() + absPath, err := obsidian.AddVault(vaultDir) + assert.NoError(t, err) + assert.Equal(t, vaultDir, absPath) + + // Verify vault was added + content, err := os.ReadFile(mockObsidianConfigFile) + assert.NoError(t, err) + + var cfg obsidian.ObsidianVaultConfig + err = json.Unmarshal(content, &cfg) + assert.NoError(t, err) + assert.Len(t, cfg.Vaults, 1) + + for _, v := range cfg.Vaults { + assert.Equal(t, vaultDir, v.Path) + } + }) + + t.Run("Rejects non-existent path", 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.AddVault("/nonexistent/path/to/vault") + assert.Error(t, err) + assert.Contains(t, err.Error(), "path does not exist") + }) + + t.Run("Rejects file path", 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) + + tmpFile := filepath.Join(t.TempDir(), "file.txt") + err = os.WriteFile(tmpFile, []byte("test"), 0644) + assert.NoError(t, err) + + _, err = obsidian.AddVault(tmpFile) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not a directory") + }) + + t.Run("Rejects duplicate vault", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + + vaultDir := t.TempDir() + configContent := `{ + "vaults": { + "abc123": { + "path": "` + vaultDir + `" + } + } + }` + err := os.WriteFile(mockObsidianConfigFile, []byte(configContent), 0644) + assert.NoError(t, err) + + _, err = obsidian.AddVault(vaultDir) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already registered") + }) + + t.Run("Adds multiple vaults", 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) + + vault1 := t.TempDir() + vault2 := t.TempDir() + + _, err = obsidian.AddVault(vault1) + assert.NoError(t, err) + + _, err = obsidian.AddVault(vault2) + assert.NoError(t, err) + + content, err := os.ReadFile(mockObsidianConfigFile) + assert.NoError(t, err) + + var cfg obsidian.ObsidianVaultConfig + err = json.Unmarshal(content, &cfg) + assert.NoError(t, err) + assert.Len(t, cfg.Vaults, 2) + }) +} + +func TestRemoveVault(t *testing.T) { + originalObsidianConfigFile := obsidian.ObsidianConfigFile + originalRunningInWSL := obsidian.RunningInWSL + defer func() { + obsidian.ObsidianConfigFile = originalObsidianConfigFile + obsidian.RunningInWSL = originalRunningInWSL + }() + + obsidian.RunningInWSL = func() bool { return false } + + t.Run("Removes vault by name", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + + configContent := `{ + "vaults": { + "abc123": { + "path": "/Users/user/Documents/Personal" + }, + "def456": { + "path": "/Users/user/Documents/Work" + } + } + }` + err := os.WriteFile(mockObsidianConfigFile, []byte(configContent), 0644) + assert.NoError(t, err) + + name, err := obsidian.RemoveVault("Personal") + assert.NoError(t, err) + assert.Equal(t, "Personal", name) + + content, err := os.ReadFile(mockObsidianConfigFile) + assert.NoError(t, err) + + var cfg obsidian.ObsidianVaultConfig + err = json.Unmarshal(content, &cfg) + assert.NoError(t, err) + assert.Len(t, cfg.Vaults, 1) + + for _, v := range cfg.Vaults { + assert.Equal(t, "/Users/user/Documents/Work", v.Path) + } + }) + + t.Run("Removes vault by path and returns resolved name", func(t *testing.T) { + mockObsidianConfigFile := mocks.CreateMockObsidianConfigFile(t) + obsidian.ObsidianConfigFile = func() (string, error) { + return mockObsidianConfigFile, nil + } + + configContent := `{ + "vaults": { + "abc123": { + "path": "/Users/user/Documents/Personal" + } + } + }` + err := os.WriteFile(mockObsidianConfigFile, []byte(configContent), 0644) + assert.NoError(t, err) + + name, err := obsidian.RemoveVault("/Users/user/Documents/Personal") + assert.NoError(t, err) + assert.Equal(t, "Personal", name) + + content, err := os.ReadFile(mockObsidianConfigFile) + assert.NoError(t, err) + + var cfg obsidian.ObsidianVaultConfig + err = json.Unmarshal(content, &cfg) + assert.NoError(t, err) + assert.Len(t, cfg.Vaults, 0) + }) + + t.Run("Returns error for non-existent vault", 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.RemoveVault("NonExistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) +}