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:
2026-01-01 21:54:36 +01:00
parent 26a46f92c1
commit 52160345bf
14 changed files with 927 additions and 16 deletions
+3
View File
@@ -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
+52 -5
View File
@@ -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")
}
+143 -1
View File
@@ -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"
}
+126
View File
@@ -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
}
+44
View File
@@ -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
}