Implement Jade CLI v1.0 MVP
Complete implementation of note management CLI with all core features: Commands: - add: Create new notes in $EDITOR with auto-generated filenames - list: Display all notes with titles, paths, and tags - search: Full-text search via ripgrep, tag-based filtering - tags: List all tags with occurrence counts - edit: Fuzzy search and edit notes by title - rm: Move notes to trash with confirmation prompt Features: - Automatic depository structure initialization (.jade/trash/) - Configurable tag prefix (default '+') - Parse title from first # heading (filename fallback) - Extract tags anywhere in content - Parse both [[wiki-links]] and [markdown](links) - Trash system with timestamps to prevent conflicts Technical: - Global config at ~/.config/jade/config.yml - Per-depository settings support - Ripgrep integration for fast search - $EDITOR integration for note editing - Comprehensive README with usage examples
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.jade/
|
||||
+148
-1
@@ -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
|
||||
|
||||
+49
-3
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user