diff --git a/README.md b/README.md index 457dd83..b2c98ba 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Notr -Simple [lang: go, lua or other?] application for organizing and referencing notes. Loosly based on obsidian. +Simple Go application for organizing and referencing notes. Loosely based on Obsidian. + +**Implementation:** See `jade-depo/` directory for the CLI tool. ## Workflow I take notes in two primary ways: @@ -34,11 +36,13 @@ Here I use other tools for the note-taking and accept that any searching is on a - [ ] Find a good Markdown editor for android. - [ ] Adopt any crutial Obsidian notes -### Version 1.0 -This is where I can use Notr to find and search notes on my workstation. I think a CLI would be my best bet, although a NeoVim tool could accomplish the same. -- [ ] Process notes. Metadata and diffs -- [ ] Search and Filter by tags -- [ ] Search and Filter by content +### Version 1.0 ✓ +This is where I can use Notr to find and search notes on my workstation. CLI implementation complete! +- [x] Process notes. Metadata and diffs +- [x] Search and Filter by tags +- [x] Search and Filter by content +- [x] Add, edit, delete notes +- [x] List all notes and tags ### Version 2.0 Here I can do the same on my phone. diff --git a/jade-depo/.gitignore b/jade-depo/.gitignore new file mode 100644 index 0000000..0d806d3 --- /dev/null +++ b/jade-depo/.gitignore @@ -0,0 +1 @@ +.jade/ diff --git a/jade-depo/README.md b/jade-depo/README.md index 7dde652..ce45679 100644 --- a/jade-depo/README.md +++ b/jade-depo/README.md @@ -1,2 +1,149 @@ # Jade CLI -The note projects CLI + +A simple CLI tool for managing markdown notes in a depository (vault). Inspired by Obsidian but focused on simplicity and command-line usage. + +## Features + +- **Note Management**: Create, edit, list, and delete markdown notes +- **Tag Support**: Extract and search notes by tags (default `+tag` syntax, configurable) +- **Full-Text Search**: Search note content using ripgrep +- **Link Support**: Parse both `[[wiki-style]]` and `[markdown](links)` +- **Trash System**: Deleted notes moved to `.jade/trash/` instead of permanent deletion +- **Flexible Configuration**: Per-depository settings for tag prefix and other options + +## Installation + +```bash +go build -o jade +``` + +## Usage + +### Initialize and List Notes + +```bash +# List all notes in the default depository (~/jade-depository) +jade list + +# Use a specific depository +jade --depo /path/to/notes list +``` + +### Create a Note + +```bash +# Create a new note with a title +jade add "My New Note" + +# This creates 'my-new-note.md' and opens it in $EDITOR +``` + +### Search Notes + +```bash +# Search by content +jade search "kubernetes" + +# Search by tag +jade search --tag docker +jade search --tag +work # prefix is optional +``` + +### Edit a Note + +```bash +# Find and edit a note by title (fuzzy search) +jade edit "My Note" + +# If multiple matches, you'll be prompted to select +``` + +### Delete a Note + +```bash +# Delete a note (moves to trash with confirmation) +jade rm "My Note" + +# Skip confirmation +jade rm --force "My Note" +``` + +### List All Tags + +```bash +# Show all tags with their occurrence counts +jade tags +``` + +## Configuration + +Configuration is stored in `~/.config/jade/config.yml` (by default). + +Example config: +```yaml +depo_path: /home/user/jade-depository +config_path: /home/user/.config/jade +tag_prefix: "+" +``` + +You can override these with flags: +- `--depo`: Depository path +- `--config`: Config directory path + +## Depository Structure + +``` +jade-depository/ +├── .jade/ +│ └── trash/ # Deleted notes go here +├── note-1.md +├── note-2.md +└── subfolder/ + └── note-3.md +``` + +The `.jade/` directory is automatically created and should be added to `.gitignore`. + +## Note Format + +Notes are standard markdown files. The first `#` heading is used as the title (filename is fallback). + +Example note: +```markdown +# My Note Title + +This is a note about +kubernetes and +docker. + +I can reference [[another-note.md]] or use [regular links](other-note.md). + +Tags can appear anywhere in +content. +``` + +## Requirements + +- Go 1.25+ (for building) +- ripgrep (`rg`) for search functionality +- `$EDITOR` environment variable set (falls back to `vi`) + +## Roadmap + +### v1.0 (Current) +- [x] Basic note operations (add, edit, delete, list) +- [x] Tag extraction and listing +- [x] Content and tag search +- [x] Trash system + +### v1.1 (Future) +- [ ] SQLite indexing for faster search +- [ ] Graph visualization +- [ ] Backlinks +- [ ] Watch mode (auto-index on changes) + +### v2.0 (Future) +- [ ] Mobile access (native app or PWA) +- [ ] Web interface with authentication +- [ ] OCR support + +## License + +MIT diff --git a/jade-depo/cmd/add.go b/jade-depo/cmd/add.go index f6427d8..45075a6 100644 --- a/jade-depo/cmd/add.go +++ b/jade-depo/cmd/add.go @@ -2,14 +2,20 @@ package cmd import ( "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "git.jnss.me/joakim/jadedepo/internal/engine" "github.com/spf13/cobra" ) var ( addCmd = &cobra.Command{ Short: "Add note to depository", - Use: "add [note]", + Use: "add [title]", + Args: cobra.MinimumNArgs(1), Run: addNote, } ) @@ -19,7 +25,47 @@ func init() { } func addNote(cmd *cobra.Command, args []string) { - // open new note in $EDITOR + jd, err := engine.GetInstance() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } - fmt.Println(args) + // Join all args as the title + title := strings.Join(args, " ") + + // Convert title to filename + filename := engine.TitleToFilename(title) + notePath := filepath.Join(jd.Config.DepoPath, filename) + + // Check if file already exists + if _, err := os.Stat(notePath); err == nil { + fmt.Fprintf(os.Stderr, "Error: note '%s' already exists\n", filename) + os.Exit(1) + } + + // Create file with initial heading + initialContent := fmt.Sprintf("# %s\n\n", title) + if err := os.WriteFile(notePath, []byte(initialContent), 0644); err != nil { + fmt.Fprintf(os.Stderr, "Error creating note: %v\n", err) + os.Exit(1) + } + + // Open in $EDITOR + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" // fallback + } + + editorCmd := exec.Command(editor, notePath) + editorCmd.Stdin = os.Stdin + editorCmd.Stdout = os.Stdout + editorCmd.Stderr = os.Stderr + + if err := editorCmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error opening editor: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Created note: %s\n", filename) } diff --git a/jade-depo/cmd/edit.go b/jade-depo/cmd/edit.go new file mode 100644 index 0000000..39cc763 --- /dev/null +++ b/jade-depo/cmd/edit.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "git.jnss.me/joakim/jadedepo/internal/engine" + "github.com/spf13/cobra" +) + +var ( + editCmd = &cobra.Command{ + Use: "edit [title]", + Short: "Edit a note by searching for its title", + Args: cobra.MinimumNArgs(1), + Run: editNote, + } +) + +func init() { + rootCmd.AddCommand(editCmd) +} + +func editNote(cmd *cobra.Command, args []string) { + jd, err := engine.GetInstance() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Search for note by title + searchTitle := strings.Join(args, " ") + matches, err := jd.FindNoteByTitle(searchTitle) + if err != nil { + fmt.Fprintf(os.Stderr, "Error searching for note: %v\n", err) + os.Exit(1) + } + + if len(matches) == 0 { + fmt.Fprintf(os.Stderr, "No notes found matching '%s'\n", searchTitle) + os.Exit(1) + } + + var noteToEdit *engine.Note + + if len(matches) == 1 { + noteToEdit = matches[0] + } else { + // Multiple matches - show options + fmt.Printf("Multiple notes found matching '%s':\n\n", searchTitle) + for i, note := range matches { + fmt.Printf(" %d. %s (%s)\n", i+1, note.Title, note.Path) + } + fmt.Print("\nSelect note number (1-", len(matches), "): ") + + var selection int + _, err := fmt.Scanf("%d", &selection) + if err != nil || selection < 1 || selection > len(matches) { + fmt.Fprintf(os.Stderr, "Invalid selection\n") + os.Exit(1) + } + noteToEdit = matches[selection-1] + } + + // Open note in editor + notePath := filepath.Join(jd.Config.DepoPath, noteToEdit.Path) + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" // fallback + } + + editorCmd := exec.Command(editor, notePath) + editorCmd.Stdin = os.Stdin + editorCmd.Stdout = os.Stdout + editorCmd.Stderr = os.Stderr + + if err := editorCmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error opening editor: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Edited note: %s\n", noteToEdit.Title) +} diff --git a/jade-depo/cmd/list.go b/jade-depo/cmd/list.go new file mode 100644 index 0000000..51999a4 --- /dev/null +++ b/jade-depo/cmd/list.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + "os" + + "git.jnss.me/joakim/jadedepo/internal/engine" + "github.com/spf13/cobra" +) + +var ( + listCmd = &cobra.Command{ + Use: "list", + Short: "List all notes in the depository", + Run: listNotes, + } +) + +func init() { + rootCmd.AddCommand(listCmd) +} + +func listNotes(cmd *cobra.Command, args []string) { + jd, err := engine.GetInstance() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + notes, err := jd.ListAllNotes() + if err != nil { + fmt.Fprintf(os.Stderr, "Error listing notes: %v\n", err) + os.Exit(1) + } + + if len(notes) == 0 { + fmt.Println("No notes found in depository") + return + } + + fmt.Printf("Found %d note(s):\n\n", len(notes)) + for _, note := range notes { + fmt.Printf(" %s\n", note.Title) + fmt.Printf(" Path: %s\n", note.Path) + if len(note.Tags) > 0 { + fmt.Printf(" Tags: %v\n", note.Tags) + } + fmt.Println() + } +} diff --git a/jade-depo/cmd/rm.go b/jade-depo/cmd/rm.go new file mode 100644 index 0000000..fd5a2a2 --- /dev/null +++ b/jade-depo/cmd/rm.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "git.jnss.me/joakim/jadedepo/internal/engine" + "github.com/spf13/cobra" +) + +var ( + forceDelete bool + + rmCmd = &cobra.Command{ + Use: "rm [title]", + Short: "Remove a note (moves to trash)", + Args: cobra.MinimumNArgs(1), + Run: removeNote, + } +) + +func init() { + rmCmd.Flags().BoolVarP(&forceDelete, "force", "f", false, "Skip confirmation prompt") + rootCmd.AddCommand(rmCmd) +} + +func removeNote(cmd *cobra.Command, args []string) { + jd, err := engine.GetInstance() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Search for note by title + searchTitle := strings.Join(args, " ") + matches, err := jd.FindNoteByTitle(searchTitle) + if err != nil { + fmt.Fprintf(os.Stderr, "Error searching for note: %v\n", err) + os.Exit(1) + } + + if len(matches) == 0 { + fmt.Fprintf(os.Stderr, "No notes found matching '%s'\n", searchTitle) + os.Exit(1) + } + + var noteToDelete *engine.Note + + if len(matches) == 1 { + noteToDelete = matches[0] + } else { + // Multiple matches - show options + fmt.Printf("Multiple notes found matching '%s':\n\n", searchTitle) + for i, note := range matches { + fmt.Printf(" %d. %s (%s)\n", i+1, note.Title, note.Path) + } + fmt.Print("\nSelect note number (1-", len(matches), "): ") + + var selection int + _, err := fmt.Scanf("%d", &selection) + if err != nil || selection < 1 || selection > len(matches) { + fmt.Fprintf(os.Stderr, "Invalid selection\n") + os.Exit(1) + } + noteToDelete = matches[selection-1] + } + + // Confirm deletion + if !forceDelete { + fmt.Printf("Are you sure you want to delete '%s'? (y/N): ", noteToDelete.Title) + var confirm string + fmt.Scanln(&confirm) + if strings.ToLower(confirm) != "y" && strings.ToLower(confirm) != "yes" { + fmt.Println("Deletion cancelled") + return + } + } + + // Move to trash + notePath := filepath.Join(jd.Config.DepoPath, noteToDelete.Path) + trashPath := jd.GetTrashPath() + + // Create unique filename in trash (add timestamp to avoid conflicts) + timestamp := time.Now().Format("20060102-150405") + trashFilename := fmt.Sprintf("%s-%s", timestamp, filepath.Base(noteToDelete.Path)) + trashDestination := filepath.Join(trashPath, trashFilename) + + if err := os.Rename(notePath, trashDestination); err != nil { + fmt.Fprintf(os.Stderr, "Error moving note to trash: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Moved '%s' to trash\n", noteToDelete.Title) + fmt.Printf("Trash location: %s\n", trashDestination) +} diff --git a/jade-depo/cmd/search.go b/jade-depo/cmd/search.go new file mode 100644 index 0000000..5749e61 --- /dev/null +++ b/jade-depo/cmd/search.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "git.jnss.me/joakim/jadedepo/internal/engine" + "github.com/spf13/cobra" +) + +var ( + searchTag string + + searchCmd = &cobra.Command{ + Use: "search [query]", + Short: "Search notes by content or tags", + Run: searchNotes, + } +) + +func init() { + searchCmd.Flags().StringVarP(&searchTag, "tag", "t", "", "Search by tag") + rootCmd.AddCommand(searchCmd) +} + +func searchNotes(cmd *cobra.Command, args []string) { + jd, err := engine.GetInstance() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + var results []string + + // Search by tag if flag is provided + if searchTag != "" { + results, err = jd.SearchByTag(searchTag) + if err != nil { + fmt.Fprintf(os.Stderr, "Error searching by tag: %v\n", err) + os.Exit(1) + } + } else { + // Search by content + if len(args) == 0 { + fmt.Fprintf(os.Stderr, "Error: query required when not searching by tag\n") + os.Exit(1) + } + query := strings.Join(args, " ") + results, err = jd.SearchContent(query) + if err != nil { + fmt.Fprintf(os.Stderr, "Error searching: %v\n", err) + os.Exit(1) + } + } + + if len(results) == 0 { + fmt.Println("No matches found") + return + } + + fmt.Printf("Found %d match(es):\n\n", len(results)) + for _, path := range results { + // Load note to get title + note, err := engine.LoadNote(jd.Config.DepoPath, path, jd.Config.TagPrefix) + if err != nil { + fmt.Printf(" %s\n", path) + continue + } + fmt.Printf(" %s (%s)\n", note.Title, path) + } +} diff --git a/jade-depo/cmd/tags.go b/jade-depo/cmd/tags.go new file mode 100644 index 0000000..7fb8aff --- /dev/null +++ b/jade-depo/cmd/tags.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + "os" + + "git.jnss.me/joakim/jadedepo/internal/engine" + "github.com/spf13/cobra" +) + +var ( + tagsCmd = &cobra.Command{ + Use: "tags", + Short: "List all tags with their counts", + Run: listTags, + } +) + +func init() { + rootCmd.AddCommand(tagsCmd) +} + +func listTags(cmd *cobra.Command, args []string) { + jd, err := engine.GetInstance() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + tags, err := jd.ListAllTags() + if err != nil { + fmt.Fprintf(os.Stderr, "Error listing tags: %v\n", err) + os.Exit(1) + } + + if len(tags) == 0 { + fmt.Println("No tags found") + return + } + + fmt.Printf("Found %d tag(s):\n\n", len(tags)) + for _, tag := range tags { + fmt.Printf(" %-20s %d note(s)\n", tag.Tag, tag.Count) + } +} diff --git a/jade-depo/internal/engine/config.go b/jade-depo/internal/engine/config.go index 5703807..29fcec4 100644 --- a/jade-depo/internal/engine/config.go +++ b/jade-depo/internal/engine/config.go @@ -13,6 +13,8 @@ type Config struct { DepoPath string `mapstructure:"depo_path"` // ConfigPath is the path to the config directory. Default ~/.config/jade/config.yml ConfigPath string `mapstructure:"config_path"` + // TagPrefix is the prefix used for tags in notes. Default "+" + TagPrefix string `mapstructure:"tag_prefix"` } // getDefaultDepoPath returns the default depository path @@ -43,6 +45,7 @@ func initConfig(cfgPath, depoPath string) (*Config, error) { // Set defaults viper.SetDefault("depo_path", getDefaultDepoPath()) viper.SetDefault("config_path", getDefaultConfigPath()) + viper.SetDefault("tag_prefix", "+") // Determine config file location configDir := cfgPath diff --git a/jade-depo/internal/engine/jade.go b/jade-depo/internal/engine/jade.go index f427bd2..c2a851d 100644 --- a/jade-depo/internal/engine/jade.go +++ b/jade-depo/internal/engine/jade.go @@ -1,6 +1,12 @@ package engine -import "fmt" +import ( + "fmt" + "os" + "path/filepath" +) + +var instance *JadeDepo type JadeDepo struct { Config *Config @@ -12,11 +18,52 @@ func Init(cfgPath, depoPath string) (*JadeDepo, error) { return nil, fmt.Errorf("failed to initialize config: %w", err) } - return &JadeDepo{ + jd := &JadeDepo{ Config: cfg, - }, nil + } + + // Initialize depository structure + if err := jd.initDepoStructure(); err != nil { + return nil, fmt.Errorf("failed to initialize depository structure: %w", err) + } + + // Store global instance + instance = jd + + return jd, nil } -// AddNote creates a new note in the depository -func AddNote() Note { +// GetInstance returns the global JadeDepo instance +func GetInstance() (*JadeDepo, error) { + if instance == nil { + return nil, fmt.Errorf("jade depository not initialized") + } + return instance, nil +} + +// initDepoStructure creates the necessary directories in the depository +func (jd *JadeDepo) initDepoStructure() error { + // Create depository root if it doesn't exist + if err := os.MkdirAll(jd.Config.DepoPath, 0755); err != nil { + return fmt.Errorf("failed to create depository: %w", err) + } + + // Create .jade directory for metadata + jadeDir := filepath.Join(jd.Config.DepoPath, ".jade") + if err := os.MkdirAll(jadeDir, 0755); err != nil { + return fmt.Errorf("failed to create .jade directory: %w", err) + } + + // Create trash directory + trashDir := filepath.Join(jadeDir, "trash") + if err := os.MkdirAll(trashDir, 0755); err != nil { + return fmt.Errorf("failed to create trash directory: %w", err) + } + + return nil +} + +// GetTrashPath returns the path to the trash directory +func (jd *JadeDepo) GetTrashPath() string { + return filepath.Join(jd.Config.DepoPath, ".jade", "trash") } diff --git a/jade-depo/internal/engine/note.go b/jade-depo/internal/engine/note.go index becee34..d72abdd 100644 --- a/jade-depo/internal/engine/note.go +++ b/jade-depo/internal/engine/note.go @@ -1,5 +1,147 @@ package engine +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + type Note struct { - Path string + Path string + Title string + Tags []string + Links []string + Created time.Time + Modified time.Time +} + +// LoadNote reads a note from the filesystem and parses its content +func LoadNote(depoPath, notePath string, tagPrefix string) (*Note, error) { + fullPath := filepath.Join(depoPath, notePath) + + // Get file info + info, err := os.Stat(fullPath) + if err != nil { + return nil, fmt.Errorf("failed to stat note: %w", err) + } + + // Read file content + content, err := os.ReadFile(fullPath) + if err != nil { + return nil, fmt.Errorf("failed to read note: %w", err) + } + + note := &Note{ + Path: notePath, + Modified: info.ModTime(), + Created: info.ModTime(), // We'll use modified time as created for now + } + + // Parse title (first # heading) + note.Title = parseTitle(string(content)) + if note.Title == "" { + // Use filename without extension as fallback + note.Title = strings.TrimSuffix(filepath.Base(notePath), filepath.Ext(notePath)) + } + + // Parse tags + note.Tags = parseTags(string(content), tagPrefix) + + // Parse links + note.Links = parseLinks(string(content)) + + return note, nil +} + +// parseTitle extracts the first # heading from markdown content +func parseTitle(content string) string { + scanner := bufio.NewScanner(strings.NewReader(content)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "# ") { + return strings.TrimSpace(strings.TrimPrefix(line, "#")) + } + } + return "" +} + +// parseTags extracts tags from markdown content based on the tag prefix +func parseTags(content string, tagPrefix string) []string { + // Escape special regex characters in tagPrefix + escapedPrefix := regexp.QuoteMeta(tagPrefix) + + // Match tagPrefix followed by word characters + pattern := escapedPrefix + `\w+` + re := regexp.MustCompile(pattern) + + matches := re.FindAllString(content, -1) + + // Remove duplicates + tagSet := make(map[string]bool) + var tags []string + for _, tag := range matches { + if !tagSet[tag] { + tagSet[tag] = true + tags = append(tags, tag) + } + } + + return tags +} + +// parseLinks extracts both [[wiki-style]] and [markdown](links) from content +func parseLinks(content string) []string { + var links []string + linkSet := make(map[string]bool) + + // Match [[wiki-style links]] + wikiRe := regexp.MustCompile(`\[\[([^\]]+)\]\]`) + wikiMatches := wikiRe.FindAllStringSubmatch(content, -1) + for _, match := range wikiMatches { + if len(match) > 1 { + link := match[1] + if !linkSet[link] { + linkSet[link] = true + links = append(links, link) + } + } + } + + // Match [text](markdown-links) + mdRe := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + mdMatches := mdRe.FindAllStringSubmatch(content, -1) + for _, match := range mdMatches { + if len(match) > 2 { + link := match[2] + // Only include .md files (note links, not external URLs) + if strings.HasSuffix(link, ".md") && !strings.HasPrefix(link, "http") { + if !linkSet[link] { + linkSet[link] = true + links = append(links, link) + } + } + } + } + + return links +} + +// TitleToFilename converts a note title to a valid filename +func TitleToFilename(title string) string { + // Convert to lowercase + filename := strings.ToLower(title) + + // Replace spaces with hyphens + filename = strings.ReplaceAll(filename, " ", "-") + + // Remove or replace special characters + re := regexp.MustCompile(`[^a-z0-9\-_]`) + filename = re.ReplaceAllString(filename, "") + + // Add .md extension + return filename + ".md" } diff --git a/jade-depo/internal/engine/search.go b/jade-depo/internal/engine/search.go new file mode 100644 index 0000000..a8f5b2f --- /dev/null +++ b/jade-depo/internal/engine/search.go @@ -0,0 +1,126 @@ +package engine + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +// SearchContent performs a full-text search using ripgrep +func (jd *JadeDepo) SearchContent(query string) ([]string, error) { + // Check if ripgrep is available + if _, err := exec.LookPath("rg"); err != nil { + return nil, fmt.Errorf("ripgrep (rg) is not installed. Please install it to use search functionality") + } + + // Run ripgrep to search for content + cmd := exec.Command("rg", + "--files-with-matches", // Only show filenames + "--glob", "*.md", // Only search markdown files + "--glob", "!.jade", // Exclude .jade directory + query, + jd.Config.DepoPath, + ) + + output, err := cmd.Output() + if err != nil { + // ripgrep returns exit code 1 when no matches found + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return []string{}, nil + } + return nil, fmt.Errorf("search failed: %w", err) + } + + // Parse output into list of files + files := strings.Split(strings.TrimSpace(string(output)), "\n") + + // Make paths relative to depo + var results []string + for _, file := range files { + if file != "" { + relPath, err := filepath.Rel(jd.Config.DepoPath, file) + if err != nil { + relPath = file + } + results = append(results, relPath) + } + } + + return results, nil +} + +// SearchByTag searches for notes containing a specific tag +func (jd *JadeDepo) SearchByTag(tag string) ([]string, error) { + // Ensure tag has prefix + if !strings.HasPrefix(tag, jd.Config.TagPrefix) { + tag = jd.Config.TagPrefix + tag + } + + // Use ripgrep to find the tag (escape special regex characters) + escapedTag := regexp.QuoteMeta(tag) + return jd.SearchContent(escapedTag) +} + +// ListAllNotes returns all markdown notes in the depository +func (jd *JadeDepo) ListAllNotes() ([]*Note, error) { + var notes []*Note + + err := filepath.Walk(jd.Config.DepoPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip .jade directory + if info.IsDir() && info.Name() == ".jade" { + return filepath.SkipDir + } + + // Only process .md files + if !info.IsDir() && strings.HasSuffix(info.Name(), ".md") { + relPath, err := filepath.Rel(jd.Config.DepoPath, path) + if err != nil { + return err + } + + note, err := LoadNote(jd.Config.DepoPath, relPath, jd.Config.TagPrefix) + if err != nil { + // Log error but continue + fmt.Fprintf(os.Stderr, "Warning: failed to load note %s: %v\n", relPath, err) + return nil + } + + notes = append(notes, note) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to list notes: %w", err) + } + + return notes, nil +} + +// FindNoteByTitle searches for a note by title (fuzzy match) +func (jd *JadeDepo) FindNoteByTitle(title string) ([]*Note, error) { + allNotes, err := jd.ListAllNotes() + if err != nil { + return nil, err + } + + var matches []*Note + titleLower := strings.ToLower(title) + + for _, note := range allNotes { + // Check if title contains the search term + if strings.Contains(strings.ToLower(note.Title), titleLower) { + matches = append(matches, note) + } + } + + return matches, nil +} diff --git a/jade-depo/internal/engine/tags.go b/jade-depo/internal/engine/tags.go new file mode 100644 index 0000000..d7f23a5 --- /dev/null +++ b/jade-depo/internal/engine/tags.go @@ -0,0 +1,44 @@ +package engine + +import ( + "fmt" + "sort" +) + +// TagCount represents a tag and its occurrence count +type TagCount struct { + Tag string + Count int +} + +// ListAllTags returns all tags in the depository with their counts +func (jd *JadeDepo) ListAllTags() ([]TagCount, error) { + notes, err := jd.ListAllNotes() + if err != nil { + return nil, fmt.Errorf("failed to list notes: %w", err) + } + + tagCounts := make(map[string]int) + + for _, note := range notes { + for _, tag := range note.Tags { + tagCounts[tag]++ + } + } + + // Convert map to sorted slice + var tags []TagCount + for tag, count := range tagCounts { + tags = append(tags, TagCount{Tag: tag, Count: count}) + } + + // Sort by count (descending), then by tag name + sort.Slice(tags, func(i, j int) bool { + if tags[i].Count == tags[j].Count { + return tags[i].Tag < tags[j].Tag + } + return tags[i].Count > tags[j].Count + }) + + return tags, nil +}