diff --git a/cmd/Denote/main.go b/cmd/Denote/main.go deleted file mode 100644 index fc4f8f3..0000000 --- a/cmd/Denote/main.go +++ /dev/null @@ -1,498 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - "time" - - "denote/internal/denote" -) - - -func handleNew(dir string, args []string) { - // Rejoin args in case acme split them oddly - fullArg := strings.Join(args, " ") - - // Show usage if no arguments - if fullArg == "" { - fmt.Fprintf(os.Stderr, `Usage: new [-f type] [-e] 'Title' [tag1 tag2 ...] - -Create a new denote note with the given title and optional tags. - -Options: - -f type File type (default: md-yaml) - Supported: org, md-yaml, md-toml, txt - -e Encrypt the file - -Examples: - new 'My Document Title' tag1 tag2 - new -f org 'Meeting Notes' meeting project - new -e 'Secret Document' confidential - new -f txt -e 'Journal Entry' journal - -Note: Title must be quoted with single quotes. -`) - os.Exit(0) - } - - fileType := "md-yaml" - encrypt := false - - // Parse flags - for { - if strings.HasPrefix(fullArg, "-f ") { - parts := strings.SplitN(fullArg, " ", 3) - if len(parts) < 3 { - fmt.Fprintf(os.Stderr, "error: missing arguments after -f\nUsage: new [-f type] [-e] 'Title' [tag1 tag2 ...]\n") - os.Exit(1) - } - fileType = parts[1] - fullArg = parts[2] - if _, ok := denote.FrontMatterTemplates[fileType]; !ok { - fmt.Fprintf(os.Stderr, "error: unsupported file type: %s\nSupported: org, md-yaml, md-toml, txt\n", fileType) - os.Exit(1) - } - } else if strings.HasPrefix(fullArg, "-e ") { - parts := strings.SplitN(fullArg, " ", 2) - if len(parts) < 2 { - fmt.Fprintf(os.Stderr, "error: missing arguments after -e\nUsage: new [-f type] [-e] 'Title' [tag1 tag2 ...]\n") - os.Exit(1) - } - encrypt = true - fullArg = parts[1] - } else { - break - } - } - - // Extract title between single quotes - titleRe := regexp.MustCompile(`'([^']+)'`) - m := titleRe.FindStringSubmatch(fullArg) - if m == nil { - fmt.Fprintf(os.Stderr, "error: title must be quoted with single quotes\nUsage: new [-f type] [-e] 'Title' [tag1 tag2 ...]\n") - os.Exit(1) - } - title := m[1] - - // Extract keywords (everything after the closing quote) - afterTitle := strings.TrimSpace(fullArg[strings.Index(fullArg, m[0])+len(m[0]):]) - var keywords []string - if afterTitle != "" { - keywords = strings.Fields(afterTitle) - } - - // Create the note - var path string - var err error - - if encrypt { - // Create encrypted note using shared function - path, err = denote.CreateEncryptedNote(dir, title, keywords, fileType, "") - } else { - // Create regular note - path, err = denote.CreateNote(dir, title, keywords, fileType, "") - } - if err != nil { - fmt.Fprintf(os.Stderr, "error creating note: %v\n", err) - os.Exit(1) - } - - // Display the newly created note - note := denote.ParseNote(path) - if err := denote.DisplayNotes([]*denote.Note{note}); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(1) - } -} - -func handleRename(args []string) { - note := handleRenameReturnNote(args) - if note != nil { - if err := denote.DisplayNotes([]*denote.Note{note}); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(1) - } - } -} - -func handleRenameReturnNote(args []string) *denote.Note { - fullArg := strings.Join(args, " ") - - if fullArg == "" { - fmt.Fprintf(os.Stderr, "error: no arguments provided to rename\n") - return nil - } - - // Extract file path - try to find where it ends - // Strategy: file path is everything before the first quoted title or until we find an existing file - var filePath string - var argsAfterPath string - - // Check if there's a quoted title - titleRe := regexp.MustCompile(`'([^']+)'`) - if m := titleRe.FindStringIndex(fullArg); m != nil { - // Everything before the quote is the file path - filePath = strings.TrimSpace(fullArg[:m[0]]) - argsAfterPath = strings.TrimSpace(fullArg[m[0]:]) - } else { - // No quoted title - try to find where file path ends by checking what exists - // Start with the whole string and work backwards - parts := strings.Fields(fullArg) - for i := len(parts); i > 0; i-- { - testPath := strings.Join(parts[:i], " ") - // Try as-is first - if _, err := os.Stat(testPath); err == nil { - filePath = testPath - if i < len(parts) { - argsAfterPath = strings.Join(parts[i:], " ") - } - break - } - // Try in denote directory if relative path - if !filepath.IsAbs(testPath) { - denotePath := filepath.Join(denote.DefaultDir(), testPath) - if _, err := os.Stat(denotePath); err == nil { - filePath = denotePath - if i < len(parts) { - argsAfterPath = strings.Join(parts[i:], " ") - } - break - } - } - } - if filePath == "" { - fmt.Fprintf(os.Stderr, "error: no valid file path found in: %s\n", fullArg) - return nil - } - } - - if !filepath.IsAbs(filePath) { - var err error - filePath, err = filepath.Abs(filePath) - if err != nil { - fmt.Fprintf(os.Stderr, "error: invalid file path: %v\n", err) - return nil - } - } - - if _, err := os.Stat(filePath); err != nil { - fmt.Fprintf(os.Stderr, "error: file not found: %s\n", filePath) - return nil - } - - // Check if title is provided (in quotes) - m := titleRe.FindStringSubmatch(argsAfterPath) - - var newTitle string - var newTags []string - - if m != nil { - // Title provided with quotes - newTitle = m[1] - - // Extract tags (everything after the closing quote) - afterTitle := strings.TrimSpace(argsAfterPath[strings.Index(argsAfterPath, m[0])+len(m[0]):]) - if afterTitle != "" { - newTags = strings.Fields(afterTitle) - } - } else if argsAfterPath != "" { - // No quotes - acme 2-1 chord passes everything as one string - // Treat everything as title if no tags look like tags (lowercase single words) - // For now, assume last words that are lowercase and short are tags - argParts := strings.Fields(argsAfterPath) - if len(argParts) > 0 { - // Find where tags start (lowercase single words at the end) - titleEnd := len(argParts) - for i := len(argParts) - 1; i >= 0; i-- { - word := argParts[i] - // If word looks like a tag (lowercase, no spaces, short) - if word == strings.ToLower(word) && len(word) < 20 && !strings.Contains(word, " ") { - titleEnd = i - } else { - break - } - } - - // If we found potential tags - if titleEnd < len(argParts) { - newTitle = strings.Join(argParts[:titleEnd], " ") - newTags = argParts[titleEnd:] - } else { - // Everything is the title - newTitle = argsAfterPath - } - } - } - - // Parse existing file - note := denote.ParseNote(filePath) - fm, err := denote.ParseFrontMatter(filePath) - if err != nil { - fmt.Fprintf(os.Stderr, "error reading file %s: %v\n", filePath, err) - return nil - } - - // Determine final title and tags - finalTitle := newTitle - finalTags := newTags - finalIdentifier := note.Identifier - - if finalTitle == "" { - // No title argument - use front matter or filename - if fm.Title != "" { - finalTitle = fm.Title - } else if note.FileTitle != "" { - finalTitle = note.FileTitle - } else if note.Title != "" { - finalTitle = note.Title - } else { - // Use filename without extension as fallback - finalTitle = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) - } - } - - if len(finalTags) == 0 { - // No tags argument - use front matter or filename tags - if len(fm.Tags) > 0 { - finalTags = fm.Tags - } else if len(note.Keywords) > 0 { - finalTags = note.Keywords - } - } - - if finalIdentifier == "" { - // No identifier in filename - use from front matter or generate new - if fm.Identifier != "" { - finalIdentifier = fm.Identifier - } else { - finalIdentifier = time.Now().Format("20060102T150405") - } - } - - // Update front matter if file supports it and we have new values - if fm.FileType != "" && (newTitle != "" || len(newTags) > 0) { - fm.Title = finalTitle - fm.Tags = finalTags - fm.Identifier = finalIdentifier - - if err := denote.UpdateFrontMatter(filePath, fm); err != nil { - fmt.Fprintf(os.Stderr, "error updating front matter for %s: %v\n", filePath, err) - return nil - } - } - - // Rename file - newPath, err := denote.RenameNote(filePath, finalTitle, finalTags, finalIdentifier) - if err != nil { - fmt.Fprintf(os.Stderr, "error renaming file %s: %v\n", filePath, err) - return nil - } - - // Return the renamed note - renamedNote := denote.ParseNote(newPath) - return renamedNote -} - -func handleOpen(dir string, args []string) { - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Usage: open \n\nOpen a note by its identifier.\n\nExample:\n open 20251016T091314\n") - os.Exit(0) - } - - identifier := args[0] - - // Search for note with this identifier - identFilter, err := denote.ParseFilter(fmt.Sprintf("date:%s", identifier)) - if err != nil { - fmt.Fprintf(os.Stderr, "error: invalid identifier: %v\n", err) - os.Exit(1) - } - - notes, err := denote.FindNotes(dir, []*denote.Filter{identFilter}) - if err != nil { - fmt.Fprintf(os.Stderr, "error searching for note: %v\n", err) - os.Exit(1) - } - - if len(notes) == 0 { - fmt.Fprintf(os.Stderr, "error: no note found with identifier %s\n", identifier) - os.Exit(1) - } - - if len(notes) > 1 { - fmt.Fprintf(os.Stderr, "warning: multiple notes found with identifier %s, opening first\n", identifier) - } - - // Open the file using appropriate method (CryptGet for .gpg files, plumb for others) - if err := denote.OpenFile(notes[0].Path); err != nil { - fmt.Fprintf(os.Stderr, "error opening file: %v\n", err) - os.Exit(1) - } -} - -func main() { - dir := flag.String("dir", denote.DefaultDir(), "denote directory") - flag.Parse() - - // Check for rename command BEFORE splitting args (to preserve spaces in paths) - if len(flag.Args()) > 0 { - firstArg := flag.Args()[0] - - // Check for batch rename (multiple lines with "rename" or "r") - if strings.Contains(firstArg, "\n") && (strings.Contains(firstArg, "rename") || strings.Contains(firstArg, "\nr") || strings.HasPrefix(firstArg, "r ")) { - lines := strings.Split(firstArg, "\n") - var renameLines []string - for _, line := range lines { - line = strings.TrimSpace(line) - if line != "" && (strings.HasPrefix(line, "rename ") || strings.HasPrefix(line, "r ")) { - // Normalize "r " to "rename " - if strings.HasPrefix(line, "r ") { - line = "rename " + strings.TrimPrefix(line, "r ") - } - renameLines = append(renameLines, line) - } - } - if len(renameLines) > 0 { - // Process each rename command - var allNotes []*denote.Note - for _, line := range renameLines { - args := []string{strings.TrimPrefix(line, "rename ")} - note := handleRenameReturnNote(args) - if note != nil { - allNotes = append(allNotes, note) - } - } - // Display all renamed notes - if len(allNotes) > 0 { - if err := denote.DisplayNotes(allNotes); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(1) - } - } - return - } - } - - // Check if first arg starts with "rename " or "r " or is exactly "rename" or "r" - if strings.HasPrefix(firstArg, "rename ") || strings.HasPrefix(firstArg, "r ") { - // Extract everything after "rename " or "r " - var rest string - if strings.HasPrefix(firstArg, "rename ") { - rest = strings.TrimPrefix(firstArg, "rename ") - } else { - rest = strings.TrimPrefix(firstArg, "r ") - } - var renameArgs []string - if rest != "" { - renameArgs = append(renameArgs, rest) - } - renameArgs = append(renameArgs, flag.Args()[1:]...) - handleRename(renameArgs) - return - } else if firstArg == "rename" || firstArg == "r" { - // "rename" or "r" as separate arg - handleRename(flag.Args()[1:]) - return - } - } - - // Split args on whitespace to handle acme mouse chording (for filters/search) - var filterArgs []string - for _, arg := range flag.Args() { - filterArgs = append(filterArgs, strings.Fields(arg)...) - } - - // Show help if ? is passed - if len(filterArgs) == 1 && filterArgs[0] == "?" { - fmt.Fprintf(os.Stderr, `Denote - manage denote notes in acme - -Usage: - Denote [filters...] [sort:TYPE] List notes matching filters - Denote new 'Title' [tags...] Create a new note - Denote + 'Title' [tags...] Alias for new - Denote rename /path ['Title'] [tags...] Rename a note - Denote r /path ['Title'] [tags...] Alias for rename - Denote open Open note by identifier - Denote ? Show this help - -Filters: - date:/regex/ Match date/identifier - title:/regex/ Match title - tag:/regex/ Match tags - /regex/ Match any field - !filter Negate filter - plain-text Exact match (no regex) - sort:TYPE Sort results (id, date, title) - default: id - -Examples: - Denote List all notes (sorted by id) - Denote sort:title List all notes sorted by title - Denote date:/2025.*/ Notes from 2025 - Denote tag:meeting sort:title Notes tagged 'meeting' sorted by title - Denote tag:journal List journal entries - Denote date:/202510.*/ !tag:journal October 2025, not journal - Denote new 'My Note' tag1 tag2 Create new note - Denote rename /path/file.md 'New Title' tag1 tag2 - -Options: - -dir path Denote directory (default: $DENOTE_DIR or ~/doc) - -For 'new' command help: Denote new -For 'rename' command help: Denote rename -`) - os.Exit(0) - } - - // Check if this is a "new" command (or "+" alias) - if len(filterArgs) > 0 && (filterArgs[0] == "new" || filterArgs[0] == "+") { - handleNew(*dir, filterArgs[1:]) - return - } - - // Check if this is an "open" command - if len(filterArgs) > 0 && filterArgs[0] == "open" { - handleOpen(*dir, filterArgs[1:]) - return - } - - var filters []*denote.Filter - sortType := denote.SortByID // Default sort - - for _, arg := range filterArgs { - if strings.HasPrefix(arg, "sort:") { - sortStr := strings.TrimPrefix(arg, "sort:") - var err error - sortType, err = denote.ParseSortType(sortStr) - if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) - } - continue - } - - filt, err := denote.ParseFilter(arg) - if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) - } - filters = append(filters, filt) - } - - notes, err := denote.FindNotes(*dir, filters) - if err != nil { - fmt.Fprintf(os.Stderr, "error reading directory: %v\n", err) - os.Exit(1) - } - - // Apply sorting - denote.SortNotes(notes, sortType) - - if err := denote.DisplayNotes(notes); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(1) - } -} diff --git a/cmd/Journal/main.go b/cmd/Journal/main.go deleted file mode 100644 index c315a55..0000000 --- a/cmd/Journal/main.go +++ /dev/null @@ -1,277 +0,0 @@ -package main - -import ( - "bufio" - "flag" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "denote/internal/denote" -) - -func configDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".config", "acme-denote") -} - -func readJournalConfig() (bool, error) { - configPath := filepath.Join(configDir(), "journal.cfg") - - file, err := os.Open(configPath) - if os.IsNotExist(err) { - // File doesn't exist, default to false - return false, nil - } - if err != nil { - return false, err - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - - if key == "always_encrypt" { - return value == "true", nil - } - } - - return false, scanner.Err() -} - -func journalDir() string { - base := denote.DefaultDir() - subdir := os.Getenv("JOURNAL_DIR") - if subdir == "" { - subdir = "journal" - } - return filepath.Join(base, subdir) -} - -func formatJournalTitle(t time.Time) string { - return t.Format("Monday 2 January 2006 15:04") -} - -func parseDate(dateStr string) (time.Time, error) { - return time.Parse("20060102", dateStr) -} - -func parseRelativeDate(relStr string, now time.Time) (time.Time, error) { - // Parse relative date like +1d, -3d, +2h, -5m, +30s - if len(relStr) < 3 { - return time.Time{}, fmt.Errorf("invalid relative date format") - } - - sign := relStr[0] - if sign != '+' && sign != '-' { - return time.Time{}, fmt.Errorf("relative date must start with + or -") - } - - unit := relStr[len(relStr)-1] - numStr := relStr[1 : len(relStr)-1] - - num := 0 - _, err := fmt.Sscanf(numStr, "%d", &num) - if err != nil { - return time.Time{}, fmt.Errorf("invalid number in relative date: %v", err) - } - - if sign == '-' { - num = -num - } - - var result time.Time - switch unit { - case 'd': - result = now.AddDate(0, 0, num) - case 'h': - result = now.Add(time.Duration(num) * time.Hour) - case 'm': - result = now.Add(time.Duration(num) * time.Minute) - case 's': - result = now.Add(time.Duration(num) * time.Second) - default: - return time.Time{}, fmt.Errorf("invalid unit: %c (use d, h, m, or s)", unit) - } - - return result, nil -} - - - -func main() { - // Check for relative date arguments before flag parsing (to avoid -1d being treated as flag) - var relativeArg string - if len(os.Args) > 1 { - arg := os.Args[1] - if (strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-")) && - len(arg) >= 3 && - (arg[len(arg)-1] == 'd' || arg[len(arg)-1] == 'h' || arg[len(arg)-1] == 'm' || arg[len(arg)-1] == 's') { - // This is a relative date, remove it from os.Args before flag parsing - relativeArg = arg - os.Args = append(os.Args[:1], os.Args[2:]...) - } - } - - fileType := flag.String("f", "md-yaml", "file type (org, md-yaml, md-toml, txt)") - encrypt := flag.Bool("e", false, "encrypt the file") - flag.Parse() - - args := flag.Args() - - // If we had a relative arg, prepend it back to args - if relativeArg != "" { - args = append([]string{relativeArg}, args...) - } - - // Read configuration - alwaysEncrypt, err := readJournalConfig() - if err != nil { - fmt.Fprintf(os.Stderr, "error reading journal config: %v\n", err) - os.Exit(1) - } - - // Determine if we should encrypt (either flag or config) - shouldEncrypt := *encrypt || alwaysEncrypt - - dir := journalDir() - - // Ensure journal directory exists - if err := os.MkdirAll(dir, 0755); err != nil { - fmt.Fprintf(os.Stderr, "error creating journal directory: %v\n", err) - os.Exit(1) - } - - now := time.Now() - - // Determine target date and action - var targetDate time.Time - - if len(args) == 0 { - // No args - today - targetDate = now - } else if args[0] == "add" { - // Journal add [YYYYMMDD] - always create new entry - var identifier string - - if len(args) > 1 { - var err error - targetDate, err = parseDate(args[1]) - if err != nil { - fmt.Fprintf(os.Stderr, "error: invalid date format (use YYYYMMDD): %v\n", err) - os.Exit(1) - } - identifier = targetDate.Format("20060102") + "T" + now.Format("150405") - // Use target date but with current time for the title - targetDate = time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), - now.Hour(), now.Minute(), now.Second(), 0, now.Location()) - } else { - targetDate = now - identifier = now.Format("20060102T150405") - } - - title := formatJournalTitle(targetDate) - var path string - var err error - - if shouldEncrypt { - path, err = denote.CreateEncryptedNote(dir, title, []string{"journal"}, *fileType, identifier) - } else { - path, err = denote.CreateNote(dir, title, []string{"journal"}, *fileType, identifier) - } - - if err != nil { - fmt.Fprintf(os.Stderr, "error creating journal entry: %v\n", err) - os.Exit(1) - } - note := denote.ParseNote(path) - if err := denote.DisplayNotes([]*denote.Note{note}); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(1) - } - return - } else { - // Check if it's a relative date (+1d, -3h, etc.) or absolute date (YYYYMMDD) - var err error - if strings.HasPrefix(args[0], "+") || strings.HasPrefix(args[0], "-") { - // Relative date - targetDate, err = parseRelativeDate(args[0], now) - if err != nil { - fmt.Fprintf(os.Stderr, "error: invalid relative date format: %v\n", err) - os.Exit(1) - } - } else { - // Absolute date (YYYYMMDD) - targetDate, err = parseDate(args[0]) - if err != nil { - fmt.Fprintf(os.Stderr, "error: invalid date format (use YYYYMMDD or +/-Nd/h/m/s): %v\n", err) - os.Exit(1) - } - } - } - - // Find journal entries for target date by searching title - // Title format in file: "Wednesday 5 November 2025 11:01" - // Title in Note struct: "wednesday 5 november 2025 1101" (hyphens converted to spaces) - // Search pattern: "wednesday 5 november 2025" (date only, no time, with spaces) - titlePattern := strings.ToLower(targetDate.Format("Monday 2 January 2006")) - titleFilter, _ := denote.ParseFilter(fmt.Sprintf("title:/%s/", titlePattern)) - tagFilter, _ := denote.ParseFilter("tag:journal") - - notes, err := denote.FindNotes(dir, []*denote.Filter{titleFilter, tagFilter}) - if err != nil { - fmt.Fprintf(os.Stderr, "error searching for journal: %v\n", err) - os.Exit(1) - } - - if len(notes) > 0 { - if err := denote.DisplayNotes(notes); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(1) - } - return - } - - // No entries found - create new entry for target date - // Use target date but with current time for the title - targetDate = time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), - now.Hour(), now.Minute(), now.Second(), 0, now.Location()) - title := formatJournalTitle(targetDate) - // Identifier is always the actual creation time - identifier := now.Format("20060102T150405") - - var path string - if shouldEncrypt { - path, err = denote.CreateEncryptedNote(dir, title, []string{"journal"}, *fileType, identifier) - } else { - path, err = denote.CreateNote(dir, title, []string{"journal"}, *fileType, identifier) - } - - if err != nil { - fmt.Fprintf(os.Stderr, "error creating journal entry: %v\n", err) - os.Exit(1) - } - note := denote.ParseNote(path) - if err := denote.DisplayNotes([]*denote.Note{note}); err != nil { - fmt.Fprintf(os.Stderr, "%v\n", err) - os.Exit(1) - } -} - diff --git a/go.mod b/go.mod index f3d1da3..0ae499e 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module denote go 1.21 -require 9fans.net/go v0.0.5 +require 9fans.net/go v0.0.7 diff --git a/go.sum b/go.sum index 5089ea2..d81a876 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -9fans.net/go v0.0.5 h1:u0H3Et5NowdVm56FDD+aWcDOao9zEo+zolNFsr8qyQk= -9fans.net/go v0.0.5/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0= +9fans.net/go v0.0.7 h1:H5CsYJTf99C8EYAQr+uSoEJnLP/iZU8RmDuhyk30iSM= +9fans.net/go v0.0.7/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0= dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= diff --git a/internal/denote/denote.go b/internal/denote/denote.go index 534997d..5717c3d 100644 --- a/internal/denote/denote.go +++ b/internal/denote/denote.go @@ -1,259 +1,119 @@ package denote import ( + "denote/internal/fs" "fmt" + "io" "os" "os/exec" "path/filepath" "regexp" - "sort" "strings" "time" - "9fans.net/go/acme" + "9fans.net/go/plan9" + "9fans.net/go/plan9/client" ) -type Note struct { - Path string - Identifier string - Signature string - Title string - Keywords []string - Extension string - FileTitle string -} - -var identifierRe = regexp.MustCompile(`^(\d{8}T\d{6})`) -var signatureRe = regexp.MustCompile(`==([^-_\.]+)`) -var titleRe = regexp.MustCompile(`--([^_\.]+)`) -var keywordsRe = regexp.MustCompile(`__(.+?)(?:\.|$)`) - -// ParseNote parses a denote filename into a Note struct -func ParseNote(path string) *Note { - name := filepath.Base(path) - note := &Note{Path: path} - - if m := identifierRe.FindStringSubmatch(name); m != nil { - note.Identifier = m[1] - } - if m := signatureRe.FindStringSubmatch(name); m != nil { - note.Signature = m[1] - } - if m := titleRe.FindStringSubmatch(name); m != nil { - note.Title = strings.ReplaceAll(m[1], "-", " ") - } - if m := keywordsRe.FindStringSubmatch(name); m != nil { - note.Keywords = strings.Split(m[1], "_") - } - note.Extension = filepath.Ext(name) - note.FileTitle = ExtractTitle(path) - - return note -} - -// Filter represents a search filter -type Filter struct { - field string // "date", "title", "tag", or "" for any - re *regexp.Regexp - negate bool -} - -// ParseFilter parses a filter string into a Filter -func ParseFilter(arg string) (*Filter, error) { - negate := strings.HasPrefix(arg, "!") - if negate { - arg = strings.TrimPrefix(arg, "!") - } - - m := regexp.MustCompile(`^(?:(date|title|tag):)?(.+)$`).FindStringSubmatch(arg) - if m == nil { - return nil, fmt.Errorf("invalid filter syntax: %s", arg) - } - - pattern := m[2] - if strings.HasPrefix(pattern, "/") && strings.HasSuffix(pattern, "/") { - pattern = strings.TrimPrefix(strings.TrimSuffix(pattern, "/"), "/") - } else { - pattern = regexp.QuoteMeta(pattern) - } - - re, err := regexp.Compile(pattern) +// readFile reads entire content from a 9P file +func readFile(f *client.Fsys, path string) (string, error) { + fid, err := f.Open(path, plan9.OREAD) if err != nil { - return nil, fmt.Errorf("invalid regex: %v", err) + return "", err } - return &Filter{field: m[1], re: re, negate: negate}, nil + defer fid.Close() + + var content []byte + buf := make([]byte, 8192) + for { + n, err := fid.Read(buf) + if err != nil && err != io.EOF { + return "", err + } + if n == 0 { + break + } + content = append(content, buf[:n]...) + } + return strings.TrimSpace(string(content)), nil } -// Matches checks if a note matches this filter -func (f *Filter) Matches(n *Note) bool { - result := false - switch f.field { - case "date": - result = f.re.MatchString(n.Identifier) - case "title": - title := n.Title - if title == "" { - title = n.FileTitle +// Search returns metadata for notes by reading from the 9P index +func Search(filters []*fs.Filter) (fs.Results, error) { + var results fs.Results + + err := fs.With9P(func(f *client.Fsys) error { + // Read index to get identifiers + indexContent, err := readFile(f, "index") + if err != nil { + return fmt.Errorf("failed to read index: %w", err) } - result = f.re.MatchString(title) - case "tag": - for _, kw := range n.Keywords { - if f.re.MatchString(kw) { - result = true - break + + lines := strings.Split(indexContent, "\n") + for _, line := range lines { + if line == "" { + continue } - } - default: // any field - if f.re.MatchString(n.Identifier) { - result = true - } else { - title := n.Title - if title == "" { - title = n.FileTitle + + parts := strings.Split(line, "|") + if len(parts) < 1 { + continue } - if f.re.MatchString(title) { - result = true - } else { - for _, kw := range n.Keywords { - if f.re.MatchString(kw) { - result = true - break - } + + identifier := strings.TrimSpace(parts[0]) + meta := &fs.Metadata{ + Identifier: identifier, + } + + // Read remaining fields from note directory + if path, err := readFile(f, identifier+"/path"); err == nil { + meta.Path = path + } + if ext, err := readFile(f, identifier+"/extension"); err == nil { + meta.Extension = ext + } + if keywords, err := readFile(f, identifier+"/keywords"); err == nil && keywords != "" { + meta.Tags = strings.Split(keywords, ",") + for i := range meta.Tags { + meta.Tags[i] = strings.TrimSpace(meta.Tags[i]) } } - } - } - if f.negate { - return !result - } - return result -} -// SortType represents different sorting options -type SortType int - -const ( - SortByID SortType = iota - SortByDate - SortByTitle -) - -// ParseSortType parses a sort string into a SortType -func ParseSortType(sortStr string) (SortType, error) { - switch strings.ToLower(sortStr) { - case "id", "identifier": - return SortByID, nil - case "date": - return SortByDate, nil - case "title": - return SortByTitle, nil - default: - return SortByID, fmt.Errorf("invalid sort type: %s (valid options: id, date, title)", sortStr) - } -} - -// SortNotes sorts a slice of notes based on the specified sort type -func SortNotes(notes []*Note, sortType SortType) { - switch sortType { - case SortByID: - sort.Slice(notes, func(i, j int) bool { - return notes[i].Identifier > notes[j].Identifier // Reverse chronological by default - }) - case SortByDate: - sort.Slice(notes, func(i, j int) bool { - return notes[i].Identifier > notes[j].Identifier // Same as ID since ID contains date - }) - case SortByTitle: - sort.Slice(notes, func(i, j int) bool { - titleI := notes[i].FileTitle - if titleI == "" { - titleI = notes[i].Title + // Read title - 9P server already returns file content title if available, + // otherwise filename-based title + if title, err := readFile(f, identifier+"/title"); err == nil { + meta.Title = title } - titleJ := notes[j].FileTitle - if titleJ == "" { - titleJ = notes[j].Title - } - return strings.ToLower(titleI) < strings.ToLower(titleJ) - }) - } -} -// FindNotes searches for notes in a directory matching filters -func FindNotes(dir string, filters []*Filter) ([]*Note, error) { - var notes []*Note - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil - } - if note := ParseNote(path); note.Identifier != "" { + // Apply filters match := true for _, filt := range filters { - if !filt.Matches(note) { + if !filt.IsMatch(meta) { match = false break } } + if match { - notes = append(notes, note) + results = append(results, meta) } } return nil }) + if err != nil { return nil, err } - // Default sort by ID (reverse chronological) - SortNotes(notes, SortByID) - - return notes, nil + return results, nil } -// DisplayNotes displays notes in an acme window -func DisplayNotes(notes []*Note) error { - w, err := acme.New() - if err != nil { - return fmt.Errorf("error creating acme window: %v", err) - } - defer w.CloseFiles() - - w.Name("+Denote") - w.Ctl("clean") - w.Clear() - - var buf strings.Builder - for _, n := range notes { - title := n.FileTitle - if title == "" { - title = n.Title - } - if title == "" { - title = "(untitled)" - } - - tags := strings.Join(n.Keywords, ", ") - fmt.Fprintf(&buf, "denote:%s | %s | %s\n", n.Identifier, title, tags) - } - - w.Write("body", []byte(buf.String())) - w.Ctl("clean") - w.Addr("#0") - w.Ctl("dot=addr") - w.Ctl("show") - - return nil -} - - -var FrontMatterTemplates = map[string]string{ +// Templates for denote frontmatter (org, md, txt) +var templates = map[string]string{ "org": `#+title: %s #+date: %s #+filetags: %s #+identifier: %s -#+signature: %s `, "md-yaml": `--- @@ -261,7 +121,6 @@ title: %s date: %s tags: %s identifier: %s -signature: %s --- `, @@ -270,7 +129,6 @@ title = %s date = %s tags = %s identifier = %s -signature = %s +++ `, @@ -278,12 +136,13 @@ signature = %s date: %s tags: %s identifier: %s -signature: %s --------------------------- `, } +// FileExtensions contains the list of file extension +// for which Denote should add front matter. var FileExtensions = map[string]string{ "org": ".org", "md-yaml": ".md", @@ -291,104 +150,54 @@ var FileExtensions = map[string]string{ "txt": ".txt", } -func FormatKeywords(keywords []string, fileType string) string { - if len(keywords) == 0 { +func formatTags(tags []string, fileType string) string { + if len(tags) == 0 { return "" } switch fileType { case "org": - return ":" + strings.Join(keywords, ":") + ":" + return ":" + strings.Join(tags, ":") + ":" case "md-yaml", "md-toml": - return "[" + strings.Join(keywords, ", ") + "]" + return "[" + strings.Join(tags, ", ") + "]" default: - return strings.Join(keywords, " ") + return strings.Join(tags, " ") } } -func CreateNote(dir, title string, keywords []string, fileType, identifier string) (string, error) { +func NewNote(dir, title string, keywords []string, fileType, identifier string) (string, error) { // Use provided identifier or generate new one if identifier == "" { identifier = time.Now().Format("20060102T150405") } - + // Format file name titleSlug := strings.ReplaceAll(strings.ToLower(title), " ", "-") titleSlug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(titleSlug, "") - + var keywordsPart string if len(keywords) > 0 { keywordsPart = "__" + strings.Join(keywords, "_") } - + ext := FileExtensions[fileType] filename := fmt.Sprintf("%s--%s%s%s", identifier, titleSlug, keywordsPart, ext) path := filepath.Join(dir, filename) - + // Generate front matter - template := FrontMatterTemplates[fileType] + template := templates[fileType] dateStr := time.Now().Format("2006-01-02 Mon 15:04") - keywordsStr := FormatKeywords(keywords, fileType) - - content := fmt.Sprintf(template, title, dateStr, keywordsStr, identifier, "") - + keywordsStr := formatTags(keywords, fileType) + + content := fmt.Sprintf(template, title, dateStr, keywordsStr, identifier) + // Write file if err := os.WriteFile(path, []byte(content), 0644); err != nil { return "", err } - + return path, nil } -func DefaultDir() string { - if d := os.Getenv("DENOTE_DIR"); d != "" { - return d - } - if d := os.Getenv("XDG_DOCUMENTS_DIR"); d != "" { - return d - } - return filepath.Join(os.Getenv("HOME"), "doc") -} - -var orgTitleRe = regexp.MustCompile(`(?m)^#\+title:\s*(.+)$`) -var mdYamlTitleRe = regexp.MustCompile(`(?ms)^---\n.*?^title:\s*(.+?)$.*?^---`) -var mdHeaderRe = regexp.MustCompile(`(?m)^#\s+(.+)$`) - -func ExtractTitle(path string) string { - ext := strings.ToLower(filepath.Ext(path)) - if ext != ".org" && ext != ".md" && ext != ".txt" { - return "" - } - - data, err := os.ReadFile(path) - if err != nil { - return "" - } - text := string(data) - - // Try org-mode #+title: first, then fall back to first heading - if ext == ".org" { - if m := orgTitleRe.FindStringSubmatch(text); m != nil { - return strings.TrimSpace(m[1]) - } - // Fallback to first heading (lines starting with *) - if m := regexp.MustCompile(`(?m)^\*+\s+(.+)$`).FindStringSubmatch(text); m != nil { - return strings.TrimSpace(m[1]) - } - } - - // Try markdown YAML front matter title: first, then fall back to # header - if ext == ".md" { - if m := mdYamlTitleRe.FindStringSubmatch(text); m != nil { - return strings.TrimSpace(strings.Trim(m[1], `"`)) - } - if m := mdHeaderRe.FindStringSubmatch(text); m != nil { - return strings.TrimSpace(m[1]) - } - } - - return "" -} - // FrontMatter represents parsed front matter from a note type FrontMatter struct { Title string @@ -397,17 +206,17 @@ type FrontMatter struct { FileType string // org, md-yaml, md-toml, txt } -// ParseFrontMatter extracts front matter from a file -func ParseFrontMatter(path string) (*FrontMatter, error) { +// ExtractFrontMatter extracts front matter from a file +func ExtractFrontMatter(path string) (*FrontMatter, error) { ext := strings.ToLower(filepath.Ext(path)) data, err := os.ReadFile(path) if err != nil { return nil, err } text := string(data) - + fm := &FrontMatter{} - + switch ext { case ".org": fm.FileType = "org" @@ -420,7 +229,7 @@ func ParseFrontMatter(path string) (*FrontMatter, error) { if m := regexp.MustCompile(`(?m)^#\+identifier:\s*(.+)$`).FindStringSubmatch(text); m != nil { fm.Identifier = strings.TrimSpace(m[1]) } - + case ".md": // Try YAML first yamlRe := regexp.MustCompile(`(?ms)^---\n(.*?)\n---`) @@ -461,7 +270,7 @@ func ParseFrontMatter(path string) (*FrontMatter, error) { } } } - + case ".txt": fm.FileType = "txt" if m := regexp.MustCompile(`(?m)^title:\s*(.+)$`).FindStringSubmatch(text); m != nil { @@ -474,7 +283,7 @@ func ParseFrontMatter(path string) (*FrontMatter, error) { fm.Identifier = strings.TrimSpace(m[1]) } } - + return fm, nil } @@ -485,22 +294,21 @@ func UpdateFrontMatter(path string, fm *FrontMatter) error { return err } text := string(data) - + dateStr := time.Now().Format("2006-01-02 Mon 15:04") - keywordsStr := FormatKeywords(fm.Tags, fm.FileType) - - var newFrontMatter string + keywordsStr := formatTags(fm.Tags, fm.FileType) + + // For org-mode, wrap date in brackets for timestamp + if fm.FileType == "org" { + dateStr = "[" + dateStr + "]" + } + + template := templates[fm.FileType] + newFrontMatter := fmt.Sprintf(template, fm.Title, dateStr, keywordsStr, fm.Identifier) + var newText string - switch fm.FileType { case "org": - newFrontMatter = fmt.Sprintf(`#+title: %s -#+date: [%s] -#+filetags: %s -#+identifier: %s -#+signature: - -`, fm.Title, dateStr, keywordsStr, fm.Identifier) // Find end of front matter (first blank line or non-#+ line) lines := strings.Split(text, "\n") endIdx := 0 @@ -515,17 +323,8 @@ func UpdateFrontMatter(path string, fm *FrontMatter) error { } else { newText = newFrontMatter + text } - - case "md-yaml": - newFrontMatter = fmt.Sprintf(`--- -title: "%s" -date: %s -tags: %s -identifier: "%s" -signature: ---- -`, fm.Title, dateStr, keywordsStr, fm.Identifier) + case "md-yaml": // Replace YAML front matter re := regexp.MustCompile(`(?s)^---\n.*?\n---\n*`) if re.MatchString(text) { @@ -533,17 +332,8 @@ signature: } else { newText = newFrontMatter + text } - - case "md-toml": - newFrontMatter = fmt.Sprintf(`+++ -title = "%s" -date = %s -tags = %s -identifier = "%s" -signature = -+++ -`, fm.Title, dateStr, keywordsStr, fm.Identifier) + case "md-toml": // Replace TOML front matter re := regexp.MustCompile(`(?s)^\+\+\+\n.*?\n\+\+\+\n*`) if re.MatchString(text) { @@ -551,16 +341,8 @@ signature = } else { newText = newFrontMatter + text } - - case "txt": - newFrontMatter = fmt.Sprintf(`title: %s -date: %s -tags: %s -identifier: %s -signature: ---------------------------- -`, fm.Title, dateStr, keywordsStr, fm.Identifier) + case "txt": // Replace text front matter re := regexp.MustCompile(`(?s)^title:.*?\n-+\n*`) if re.MatchString(text) { @@ -571,117 +353,108 @@ signature: default: return fmt.Errorf("unsupported file type: %s", fm.FileType) } - + return os.WriteFile(path, []byte(newText), 0644) } -// RenameNote renames a note file according to denote convention -func RenameNote(oldPath, title string, keywords []string, identifier string) (string, error) { +// Rename renames a note file according to denote convention +func Rename(oldPath, title string, keywords []string, identifier string) (string, error) { if identifier == "" { identifier = time.Now().Format("20060102T150405") } - + titleSlug := strings.ReplaceAll(strings.ToLower(title), " ", "-") titleSlug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(titleSlug, "") - + var keywordsPart string if len(keywords) > 0 { keywordsPart = "__" + strings.Join(keywords, "_") } - + ext := filepath.Ext(oldPath) dir := filepath.Dir(oldPath) filename := fmt.Sprintf("%s--%s%s%s", identifier, titleSlug, keywordsPart, ext) newPath := filepath.Join(dir, filename) - + if err := os.Rename(oldPath, newPath); err != nil { return "", err } - + return newPath, nil } -// CheckCryptPutAvailable checks if CryptPut is installed and available -func CheckCryptPutAvailable() error { - _, err := exec.LookPath("CryptPut") - if err != nil { - return fmt.Errorf("CryptPut is not installed.\ngit clone https://github.com/lneely/acme-crypt") +// NewNoteEncrypted creates a new encrypted denote note using CryptPut +func NewNoteEncrypted(dir, title string, keywords []string, fileType, identifier string) (string, error) { + if _, err := exec.LookPath("CryptPut"); err != nil { + fmt.Fprintf(os.Stderr, "CryptPut is not installed.\ngit clone https://github.com/lneely/acme-crypt\n") + return "", fmt.Errorf("CryptPut not available") } - return nil -} -// CheckCryptGetAvailable checks if CryptGet is installed and available -func CheckCryptGetAvailable() bool { - _, err := exec.LookPath("CryptGet") - return err == nil -} - -// CreateEncryptedNote creates a new encrypted denote note using CryptPut -func CreateEncryptedNote(dir, title string, keywords []string, fileType, identifier string) (string, error) { - // Check if CryptPut is available - if err := CheckCryptPutAvailable(); err != nil { - return "", err - } - - // Check if ACME_CRYPT_RCPT is set - if os.Getenv("ACME_CRYPT_RCPT") == "" { - return "", fmt.Errorf("ACME_CRYPT_RCPT environment variable is not set.\nSet it to your GPG recipient email: export ACME_CRYPT_RCPT=\"your@email.com\"") - } - // Use provided identifier or generate new one if identifier == "" { identifier = time.Now().Format("20060102T150405") } - + // Format file name (without .gpg extension - CryptPut will add it) titleSlug := strings.ReplaceAll(strings.ToLower(title), " ", "-") titleSlug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(titleSlug, "") - + var keywordsPart string if len(keywords) > 0 { keywordsPart = "__" + strings.Join(keywords, "_") } - + ext := FileExtensions[fileType] filename := fmt.Sprintf("%s--%s%s%s", identifier, titleSlug, keywordsPart, ext) plainPath := filepath.Join(dir, filename) - - // Generate front matter content - template := FrontMatterTemplates[fileType] + + template := templates[fileType] dateStr := time.Now().Format("2006-01-02 Mon 15:04") - keywordsStr := FormatKeywords(keywords, fileType) - - content := fmt.Sprintf(template, title, dateStr, keywordsStr, identifier, "") - - // Use CryptPut to create encrypted file + keywordsStr := formatTags(keywords, fileType) + content := fmt.Sprintf(template, title, dateStr, keywordsStr, identifier) + cmd := exec.Command("CryptPut", plainPath) cmd.Stdin = strings.NewReader(content) - if err := cmd.Run(); err != nil { return "", fmt.Errorf("failed to create encrypted file: %v", err) } - - // CryptPut creates the file with .gpg extension encryptedPath := plainPath + ".gpg" - + return encryptedPath, nil } -// OpenFile opens a file using the appropriate method based on file extension -func OpenFile(filePath string) error { +// Open opens a file using the appropriate method based on file extension +func Open(filePath string) error { if strings.HasSuffix(strings.ToLower(filePath), ".gpg") { - // Handle .gpg files with CryptGet - if !CheckCryptGetAvailable() { + if _, err := exec.LookPath("CryptGet"); err != nil { fmt.Fprintf(os.Stderr, "CryptGet is not installed.\ngit clone https://github.com/lneely/acme-crypt\n") return fmt.Errorf("CryptGet not available") } - cmd := exec.Command("CryptGet", filePath) return cmd.Run() } else { - // Use normal plumb for non-encrypted files cmd := exec.Command("plumb", filePath) return cmd.Run() } } +// IdentifierToPath converts a denote identifier to a file path +func IdentifierToPath(identifier string) (string, error) { + fmt.Println("IdentifierToPath: ", identifier) + filter, err := fs.NewFilter(fmt.Sprintf("date:%s", identifier)) + if err != nil { + return "", fmt.Errorf("invalid identifier: %w", err) + } + + notes, err := Search([]*fs.Filter{filter}) + if err != nil { + return "", fmt.Errorf("failed to search for note: %w", err) + } + + if len(notes) == 0 { + return "", fmt.Errorf("no note found with identifier %s", identifier) + } + fmt.Println("IdentifierToPath: ", notes[0].Path) + + return notes[0].Path, nil +} diff --git a/internal/fs/fs.go b/internal/fs/fs.go new file mode 100644 index 0000000..1ce7442 --- /dev/null +++ b/internal/fs/fs.go @@ -0,0 +1,825 @@ +package fs + +import ( + "fmt" + "io" + "net" + "os" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + "sync" + + "9fans.net/go/plan9" + "9fans.net/go/plan9/client" +) + +// Metadata is the metadata encoded into Denote-style +// file names. +type Metadata struct { + Path string + Identifier string + Title string + Tags []string + Extension string +} +type Results []*Metadata + +func (es Results) Bytes() []byte { + var buf strings.Builder + for _, e := range es { + title := e.Title + if title == "" { + title = "(untitled)" + } + + tags := strings.Join(e.Tags, ", ") + fmt.Fprintf(&buf, "denote:%s | %s | %s\n", e.Identifier, title, tags) + } + return []byte(buf.String()) +} + +// Filter matches a given field in a Result to a regular expression +type Filter struct { + field FilterField + re *regexp.Regexp + negate bool +} + +type Filters []*Filter + +type FilterField string + +const ( + FilterDate FilterField = "date" + FilterTitle FilterField = "title" + FilterTag FilterField = "tag" + FilterAny FilterField = "" +) + +// Parse converts a slice of strings of the form "tag:", +// "date:", "title:''" into a Filters list +func (fs Filters) Parse(S []string) (Filters, error) { + for _, fa := range S { + f, err := NewFilter(fa) + if err != nil { + return nil, fmt.Errorf("failed to list notes: %w", err) + } + fs = append(fs, f) + } + return fs, nil +} + +// NewFilter constructs a Filter from a filter string. arg takes the form +// field:criteria, e.g., tag:/dev|meeting/, date:20251101. +func NewFilter(arg string) (*Filter, error) { + negate := strings.HasPrefix(arg, "!") + if negate { + arg = strings.TrimPrefix(arg, "!") + } + + m := regexp.MustCompile(`^(?:(date|title|tag):)?(.+)$`).FindStringSubmatch(arg) + if m == nil { + return nil, fmt.Errorf("invalid filter syntax: %s", arg) + } + + pattern := m[2] + if strings.HasPrefix(pattern, "/") && strings.HasSuffix(pattern, "/") { + pattern = strings.TrimPrefix(strings.TrimSuffix(pattern, "/"), "/") + } else { + pattern = regexp.QuoteMeta(pattern) + } + + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid regex: %v", err) + } + + return &Filter{field: FilterField(m[1]), re: re, negate: negate}, nil +} + +// IsMatch checks if a note matches this filter +func (f *Filter) IsMatch(n *Metadata) bool { + result := false + switch f.field { + case FilterDate: + result = f.re.MatchString(n.Identifier) + case FilterTitle: + result = f.re.MatchString(n.Title) + case FilterTag: + result = slices.ContainsFunc(n.Tags, func(kw string) bool { + return f.re.MatchString(kw) + }) + case FilterAny: // any field + if f.re.MatchString(n.Identifier) { + result = true + } else if f.re.MatchString(n.Title) { + result = true + } else { + result = slices.ContainsFunc(n.Tags, func(kw string) bool { + return f.re.MatchString(kw) + }) + } + default: + return false + } + if f.negate { + return !result + } + return result +} + +type SortBy string + +const ( + SortById SortBy = "id" + SortByDate SortBy = "date" + SortByTitle SortBy = "title" +) + +type SortOrder int + +const ( + SortOrderAsc SortOrder = iota + SortOrderDesc +) + +// Sort organizes a list of notes by sortType and order using metadata. +func Sort(md Results, sortType SortBy, order SortOrder) { + switch sortType { + case SortById, SortByDate: + sort.Slice(md, func(i, j int) bool { + if order == SortOrderAsc { + return md[i].Identifier < md[j].Identifier // Reverse chronological by default + } else { + return md[i].Identifier > md[j].Identifier // Reverse chronological by default + } + }) + case SortByTitle: + sort.Slice(md, func(i, j int) bool { + if order == SortOrderAsc { + return strings.ToLower(md[i].Title) < strings.ToLower(md[j].Title) + } else { + return strings.ToLower(md[i].Title) > strings.ToLower(md[j].Title) + } + }) + default: + sort.Slice(md, func(i, j int) bool { + return md[i].Identifier > md[j].Identifier // Reverse chronological by default + }) + } +} + +// File types in our filesystem +const ( + QTDir = plan9.QTDIR + QTFile = plan9.QTFILE +) + +// Qid paths - we'll use a simple scheme: +// 0: root +// 1-1000000: note directories (qid = 1 + note_index) +// 1000001+: files (qid = 1000001 + note_index*100 + file_type) +const ( + qidRoot = 0 + qidNoteBase = 1 + qidFileBase = 1000001 + qidIndex = 999999 +) + +// File types within a note directory +const ( + fileTypePath = iota + fileTypeTitle + fileTypeKeywords + fileTypeExtension +) + +var fileNames = []string{"path", "title", "keywords", "extension"} + +type server struct { + dir string + notes Results + mu sync.RWMutex + fids map[uint32]*fid +} + +type fid struct { + qid plan9.Qid + path string + offset int64 +} + +var srv *server + +// extractMetadata extracts Denote metadata from a filename +func extractMetadata(path string) *Metadata { + fname := filepath.Base(path) + note := &Metadata{Path: path} + + if m := regexp.MustCompile(`^(\d{8}T\d{6})`).FindStringSubmatch(fname); m != nil { + note.Identifier = m[1] + } + + // Extract title from filename + filenameTitle := "" + if m := regexp.MustCompile(`--([^_\.]+)`).FindStringSubmatch(fname); m != nil { + filenameTitle = strings.ReplaceAll(m[1], "-", " ") + } + + // Try to get title from file content, fall back to filename + fileContentTitle := extractTitle(path) + if fileContentTitle != "" { + note.Title = fileContentTitle + } else { + note.Title = filenameTitle + } + + if m := regexp.MustCompile(`__(.+?)(?:\.|$)`).FindStringSubmatch(fname); m != nil { + note.Tags = strings.Split(m[1], "_") + } + note.Extension = filepath.Ext(fname) + + return note +} + +func extractTitle(path string) string { + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".org" && ext != ".md" && ext != ".txt" { + return "" + } + + data, err := os.ReadFile(path) + if err != nil { + return "" + } + text := string(data) + + // Try org-mode #+title: first, then fall back to first heading + if ext == ".org" { + if m := regexp.MustCompile(`(?m)^#\+title:\s*(.+)$`).FindStringSubmatch(text); m != nil { + return strings.TrimSpace(m[1]) + } + // Fallback to first heading (lines starting with *) + if m := regexp.MustCompile(`(?m)^\*+\s+(.+)$`).FindStringSubmatch(text); m != nil { + return strings.TrimSpace(m[1]) + } + } + + // Try markdown YAML front matter title: first, then fall back to # header + if ext == ".md" { + if m := regexp.MustCompile(`(?ms)^---\n.*?^title:\s*(.+?)$.*?^---`).FindStringSubmatch(text); m != nil { + return strings.TrimSpace(strings.Trim(m[1], `"`)) + } + if m := regexp.MustCompile(`(?m)^#\s+(.+)$`).FindStringSubmatch(text); m != nil { + return strings.TrimSpace(m[1]) + } + } + + return "" +} + +// Getdir returns the denote directory +func getdir() string { + return fmt.Sprintf("%s/doc", os.Getenv("HOME")) +} + +// loadData returns metadata for notes in a directory matching given filters +func loadData(filters []*Filter) (Results, error) { + var notes Results + err := filepath.Walk(getdir(), func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if note := extractMetadata(path); note.Identifier != "" { + match := true + for _, filt := range filters { + if !filt.IsMatch(note) { + match = false + break + } + } + if match { + notes = append(notes, note) + } + } + return nil + }) + if err != nil { + return nil, err + } + + return notes, nil +} + +func With9P(fn func(*client.Fsys) error) error { + ns := client.Namespace() + if ns == "" { + return fmt.Errorf("failed to get namespace") + } + + conn, err := client.DialService("denote") + if err != nil { + return fmt.Errorf("failed to connect to denote service: %w", err) + } + defer conn.Close() + + root, err := conn.Attach(nil, "denote", "") + if err != nil { + return fmt.Errorf("failed to attach: %w", err) + } + defer root.Close() + + return fn(root) +} + +// StartServer starts the 9P fileserver in the background +func StartServer() error { + if srv != nil { + return fmt.Errorf("server already running") + } + + // Load all notes + notes, err := loadData([]*Filter{}) + if err != nil { + return fmt.Errorf("failed to load notes: %w", err) + } + + srv = &server{ + dir: getdir(), + notes: notes, + fids: make(map[uint32]*fid), + } + + // Get namespace and create Unix socket path + ns := client.Namespace() + if ns == "" { + return fmt.Errorf("failed to get namespace") + } + + sockPath := ns + "/denote" + + // Remove old socket if it exists + os.Remove(sockPath) + + // Create Unix domain socket listener + listener, err := net.Listen("unix", sockPath) + if err != nil { + return fmt.Errorf("failed to listen on socket: %w", err) + } + + // Start accepting connections in background + go srv.acceptLoop(listener) + + return nil +} + +func (s *server) acceptLoop(listener net.Listener) { + defer listener.Close() + + for { + conn, err := listener.Accept() + if err != nil { + fmt.Fprintf(os.Stderr, "denote fs: accept error: %v\n", err) + return + } + + go s.serve(conn) + } +} + +func (s *server) serve(conn net.Conn) { + defer conn.Close() + + for { + fc, err := plan9.ReadFcall(conn) + if err != nil { + if err != io.EOF { + fmt.Fprintf(os.Stderr, "denote fs: read error: %v\n", err) + } + return + } + + rfc := s.handle(fc) + if err := plan9.WriteFcall(conn, rfc); err != nil { + fmt.Fprintf(os.Stderr, "denote fs: write error: %v\n", err) + return + } + } +} + +func (s *server) handle(fc *plan9.Fcall) *plan9.Fcall { + switch fc.Type { + case plan9.Tversion: + return s.version(fc) + case plan9.Tauth: + return errorFcall(fc, "denote: authentication not required") + case plan9.Tattach: + return s.attach(fc) + case plan9.Twalk: + return s.walk(fc) + case plan9.Topen: + return s.open(fc) + case plan9.Tread: + return s.read(fc) + case plan9.Tstat: + return s.stat(fc) + case plan9.Tclunk: + return s.clunk(fc) + default: + return errorFcall(fc, "operation not supported") + } +} + +func (s *server) version(fc *plan9.Fcall) *plan9.Fcall { + msize := min(fc.Msize, 8192) + return &plan9.Fcall{ + Type: plan9.Rversion, + Tag: fc.Tag, + Msize: msize, + Version: "9P2000", + } +} + +func (s *server) attach(fc *plan9.Fcall) *plan9.Fcall { + s.mu.Lock() + defer s.mu.Unlock() + + qid := plan9.Qid{ + Type: QTDir, + Path: qidRoot, + } + + s.fids[fc.Fid] = &fid{ + qid: qid, + path: "/", + } + + return &plan9.Fcall{ + Type: plan9.Rattach, + Tag: fc.Tag, + Qid: qid, + } +} + +func (s *server) walk(fc *plan9.Fcall) *plan9.Fcall { + s.mu.Lock() + defer s.mu.Unlock() + + f, ok := s.fids[fc.Fid] + if !ok { + return errorFcall(fc, "fid not found") + } + + // If no wnames, this is a clone operation + if len(fc.Wname) == 0 { + newFid := &fid{ + qid: f.qid, + path: f.path, + } + s.fids[fc.Newfid] = newFid + return &plan9.Fcall{ + Type: plan9.Rwalk, + Tag: fc.Tag, + Wqid: []plan9.Qid{}, + } + } + + // Walk the path + path := f.path + qids := []plan9.Qid{} + + for _, name := range fc.Wname { + if path == "/" { + // Walking from root - should be a note ID + found := false + if "index" == name { + qid := plan9.Qid{ + Type: QTFile, + Path: uint64(qidIndex), + } + qids = append(qids, qid) + path = "/index" + found = true + } else { + for i, note := range s.notes { + if note.Identifier == name { + qid := plan9.Qid{ + Type: QTDir, + Path: uint64(qidNoteBase + i), + } + qids = append(qids, qid) + path = "/" + name + found = true + break + } + } + } + if !found { + return errorFcall(fc, "file not found") + } + } else { + // Walking from note dir - should be a file + found := false + for i, fname := range fileNames { + if fname == name { + noteIdx := s.pathToNoteIndex(path) + qid := plan9.Qid{ + Type: QTFile, + Path: uint64(qidFileBase + noteIdx*100 + i), + } + qids = append(qids, qid) + path = path + "/" + name + found = true + break + } + } + if !found { + return errorFcall(fc, "file not found") + } + } + } + + newFid := &fid{ + qid: qids[len(qids)-1], + path: path, + } + s.fids[fc.Newfid] = newFid + + return &plan9.Fcall{ + Type: plan9.Rwalk, + Tag: fc.Tag, + Wqid: qids, + } +} + +func (s *server) open(fc *plan9.Fcall) *plan9.Fcall { + s.mu.RLock() + defer s.mu.RUnlock() + + f, ok := s.fids[fc.Fid] + if !ok { + return errorFcall(fc, "fid not found") + } + + return &plan9.Fcall{ + Type: plan9.Ropen, + Tag: fc.Tag, + Qid: f.qid, + } +} + +func (s *server) read(fc *plan9.Fcall) *plan9.Fcall { + s.mu.Lock() + defer s.mu.Unlock() + + f, ok := s.fids[fc.Fid] + if !ok { + return errorFcall(fc, "fid not found") + } + + var data []byte + + if f.qid.Type&QTDir != 0 { + // Reading a directory + if fc.Offset == 0 { + f.offset = 0 + } + data = s.readDir(f.path, int64(fc.Offset), fc.Count) + } else { + // Reading a file + content := s.getFileContent(f.path) + offset := int64(fc.Offset) + if offset >= int64(len(content)) { + data = []byte{} + } else { + end := min(offset+int64(fc.Count), int64(len(content))) + data = []byte(content[offset:end]) + } + } + + return &plan9.Fcall{ + Type: plan9.Rread, + Tag: fc.Tag, + Count: uint32(len(data)), + Data: data, + } +} + +func (s *server) stat(fc *plan9.Fcall) *plan9.Fcall { + s.mu.RLock() + defer s.mu.RUnlock() + + f, ok := s.fids[fc.Fid] + if !ok { + return errorFcall(fc, "fid not found") + } + + dir := s.pathToDir(f.path, f.qid) + stat, err := dir.Bytes() + if err != nil { + return errorFcall(fc, err.Error()) + } + + return &plan9.Fcall{ + Type: plan9.Rstat, + Tag: fc.Tag, + Stat: stat, + } +} + +func (s *server) clunk(fc *plan9.Fcall) *plan9.Fcall { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.fids, fc.Fid) + + return &plan9.Fcall{ + Type: plan9.Rclunk, + Tag: fc.Tag, + } +} + +func (s *server) readDir(path string, offset int64, count uint32) []byte { + var dirs []plan9.Dir + + if path == "/" { + // add index node + dirs = append(dirs, plan9.Dir{ + Qid: plan9.Qid{ + Type: QTFile, + Path: uint64(qidIndex), + }, + Mode: 0444, + Name: "index", + Uid: "denote", + Gid: "denote", + Muid: "denote", + Length: 0, + }) + // List all note IDs + for i, note := range s.notes { + dirs = append(dirs, plan9.Dir{ + Qid: plan9.Qid{ + Type: QTDir, + Path: uint64(qidNoteBase + 1 + i), + }, + Mode: plan9.DMDIR | 0555, + Name: note.Identifier, + Uid: "denote", + Gid: "denote", + Muid: "denote", + Length: 0, + }) + } + } else if path == "/index" { + + } else { + // List files in a note directory + noteIdx := s.pathToNoteIndex(path) + for i, fname := range fileNames { + content := s.getFileContent(path + "/" + fname) + dirs = append(dirs, plan9.Dir{ + Qid: plan9.Qid{ + Type: QTFile, + Path: uint64(qidFileBase + noteIdx*100 + i), + }, + Mode: 0444, + Name: fname, + Uid: "denote", + Gid: "denote", + Muid: "denote", + Length: uint64(len(content)), + }) + } + } + + // Serialize all directory entries to bytes first + var allData []byte + for _, dir := range dirs { + stat, _ := dir.Bytes() + allData = append(allData, stat...) + } + + // Return slice starting from offset + if offset >= int64(len(allData)) { + return []byte{} + } + + end := offset + int64(count) + if end > int64(len(allData)) { + end = int64(len(allData)) + } + + return allData[offset:end] +} + +func (s *server) pathToDir(path string, qid plan9.Qid) plan9.Dir { + name := path + if path == "/" { + name = "." + } else if strings.Contains(path, "/") { + parts := strings.Split(path, "/") + name = parts[len(parts)-1] + } + + mode := uint32(0444) + length := uint64(0) + content := "" + + if qid.Type&QTDir != 0 { + mode = plan9.DMDIR | 0555 + } else { + content = s.getFileContent(path) + length = uint64(len(content)) + } + + return plan9.Dir{ + Qid: qid, + Mode: plan9.Perm(mode), + Name: name, + Uid: "denote", + Gid: "denote", + Muid: "denote", + Length: length, + } +} + +func (s *server) pathToNoteIndex(path string) int { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) == 0 { + return -1 + } + + noteID := parts[0] + for i, note := range s.notes { + if note.Identifier == noteID { + return i + } + } + return -1 +} + +func (s *server) getIndexContent() string { + content := "" + for _, n := range s.notes { + content += fmt.Sprintf("%s|%s|", n.Identifier, n.Title) + for _, t := range n.Tags { + content += fmt.Sprintf("%s,", t) + } + content = strings.TrimSuffix(content, ",") + content += "\n" + } + return content +} + +func (s *server) getFileContent(path string) string { + if path == "/index" { + return s.getIndexContent() + } + + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) != 2 { + return "" + } + + noteID := parts[0] + fileName := parts[1] + + var note *Metadata + for _, n := range s.notes { + if n.Identifier == noteID { + note = n + break + } + } + + if note == nil { + return "" + } + + switch fileName { + case "path": + return note.Path + case "title": + return note.Title + case "keywords": + return strings.Join(note.Tags, ",") + case "extension": + return note.Extension + } + return "" +} + +func errorFcall(fc *plan9.Fcall, msg string) *plan9.Fcall { + return &plan9.Fcall{ + Type: plan9.Rerror, + Tag: fc.Tag, + Ename: msg, + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..ff57c0f --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,87 @@ +package ui + +import ( + "fmt" + + a "9fans.net/go/acme" +) + +type Window = a.Win + +// WindowOpen shows the window with a given name if it exists, +// otherwise creates it +func WindowOpen(name string) (*a.Win, error) { + if w := a.Show(name); w != nil { + return w, nil + } + w, err := a.New() + if err != nil { + return nil, fmt.Errorf("failed to open prompt window: %w", err) + } + + if err := w.Name(name); err != nil { + w.Del(true) + return nil, fmt.Errorf("failed to set prompt window name: %w", err) + } + return w, nil +} + +// WindowDirty marks the window as "dirty" +// (dirty=true) or "clean" (dirty=false). +func WindowDirty(w *a.Win, dirty bool) { + if dirty { + w.Ctl("dirty") + } else { + w.Ctl("clean") + } +} + +// DotToAddr positions the cursor in a window to the given addr. +// addr follows the ':' in a fileaddr (e.g., '$', '#0', '<linenum>', +// '<range>') +func DotToAddr(w *a.Win, addr string) { + w.Addr(addr) + w.Ctl("dot=addr") + w.Ctl("show") +} + +// WindowClear clears a window's contents +func WindowClear(w *a.Win) { + w.Clear() +} + +// TagSet sets the tag of the window with the given name. +func TagSet(w *a.Win, tag string) error { + if _, err := w.Write("tag", []byte(tag)); err != nil { + w.Del(true) + return fmt.Errorf("failed to set tag: %w", err) + } + return nil +} + +// BodyWrite writes the bytes s to the window body at address addr. +// addr is what follows the ":" in a file address, e.g., "$" for EOF, +// "1" for BOF, "," for the full file, "1,20" for lines 1-20. +func BodyWrite(w *a.Win, addr string, s []byte) error { + if err := w.Addr(addr); err != nil { + return fmt.Errorf("failed to seek to end of body: %w", err) + } + + _, err := w.Write("data", s) + if err != nil { + return fmt.Errorf("failed to append data: %w", err) + } + + return nil +} + +// BodyRead reads the full contents from the window of +// the given name. +func BodyRead(w *a.Win) ([]byte, error) { + data, err := w.ReadAll("body") + if err != nil { + return []byte(""), fmt.Errorf("failed to read from window: %w", err) + } + + return data, nil +} diff --git a/main b/main deleted file mode 100755 index 5b8ec4c..0000000 Binary files a/main and /dev/null differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..c40b09e --- /dev/null +++ b/main.go @@ -0,0 +1,204 @@ +package main + +import ( + "denote/internal/denote" + "denote/internal/fs" + "denote/internal/ui" + "fmt" + "log" + "os" + "strings" + "unicode" +) + +const ( + wname = "/Denote/" +) + +func main() { + var err error + var w *ui.Window + args := os.Args[1:] + if len(args) == 1 { + if identifier, ok := strings.CutPrefix(args[0], "denote:"); ok { + path, err := denote.IdentifierToPath(identifier) + if err != nil { + fmt.Fprintf(os.Stderr, "error finding note: %v\n", err) + os.Exit(1) + } + fmt.Println(path) + denote.Open(path) + return + } else { + fmt.Println("Usage:") + fmt.Println("Denote is designed to be used in Acme, however it does accept one argument:") + fmt.Println() + fmt.Println("\tDenote denote:<identifier> - opens denote file by identifier") + fmt.Println() + } + } + + // 9p server startup + if err := fs.StartServer(); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to start fileserver: %v\n", err) + } + + // open window + if w, err = ui.WindowOpen(wname); err != nil { + log.Fatal(err) + } + defer w.CloseFiles() + + if err = ui.TagSet(w, "Reset"); err != nil { + log.Fatal(err) + } + + // get initial results + rs, err := denote.Search(fs.Filters{}) + if err != nil { + panic(err) + } + fs.Sort(rs, fs.SortById, fs.SortOrderDesc) + refreshWindow(w, rs) + + // event loop + for e := range w.EventChan() { + switch e.C2 { + case 'x', 'X': + switch string(e.Text) { + case "Reset": + rs, err := denote.Search(fs.Filters{}) + if err != nil { + panic(err) + } + fs.Sort(rs, fs.SortById, fs.SortOrderDesc) + refreshWindow(w, rs) + case "Look": + // Use e.Arg for the search arguments + performSearch(w, string(e.Arg)) + default: + w.WriteEvent(e) + } + case 'l', 'L': + w.WriteEvent(e) + default: + w.WriteEvent(e) + } + } +} + +func performSearch(w *ui.Window, searchText string) { + args := parseChordedArgs(searchText) + + var filterArgs []string + sortBy := fs.SortById + sortOrder := fs.SortOrderDesc + + for _, arg := range args { + if sortSpec, ok := strings.CutPrefix(arg, "sort:"); ok { + parts := strings.Split(sortSpec, ",") + + switch parts[0] { + case "id", "date": + sortBy = fs.SortById + case "title": + sortBy = fs.SortByTitle + } + + if len(parts) > 1 { + switch parts[1] { + case "asc": + sortOrder = fs.SortOrderAsc + case "desc": + sortOrder = fs.SortOrderDesc + } + } else { + sortOrder = fs.SortOrderAsc + } + } else { + filterArgs = append(filterArgs, arg) + } + } + + filters, err := fs.Filters{}.Parse(filterArgs) + if err != nil { + log.Printf("filter parse error: %v", err) + return + } + + rs, err := denote.Search(filters) + if err != nil { + log.Printf("search error: %v", err) + return + } + fs.Sort(rs, sortBy, sortOrder) + + refreshWindow(w, rs) +} + +func refreshWindow(w *ui.Window, rs fs.Results) { + // write initial results to window + content := "" + for _, n := range rs { + content += fmt.Sprintf("denote:%s | %s |", n.Identifier, n.Title) + for _, t := range n.Tags { + content += fmt.Sprintf("%s,", t) + } + content = strings.TrimSuffix(content, ",") + content += "\n" + } + ui.BodyWrite(w, ",", []byte(content)) +} + +// parseChordedArgs parses space-separated arguments, handling quoted strings +func parseChordedArgs(s string) []string { + var args []string + var current strings.Builder + inQuote := false + escaped := false + + for i, r := range s { + if escaped { + current.WriteRune(r) + escaped = false + continue + } + + switch r { + case '\\': + if inQuote { + // Look ahead to see if next char is quote or backslash + if i+1 < len(s) && (s[i+1] == '"' || s[i+1] == '\\') { + escaped = true + } else { + current.WriteRune(r) + } + } else { + current.WriteRune(r) + } + case '"': + inQuote = !inQuote + case ' ', '\t', '\n': + if inQuote { + current.WriteRune(r) + } else if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + default: + current.WriteRune(r) + } + } + + // Add final argument if any + if current.Len() > 0 { + args = append(args, current.String()) + } + + // Trim whitespace from args + for i := range args { + args[i] = strings.TrimFunc(args[i], unicode.IsSpace) + } + + return args +} diff --git a/mkfile b/mkfile index 79efdba..7faf1e1 100644 --- a/mkfile +++ b/mkfile @@ -1,6 +1,5 @@ install:V: - go build -o $HOME/bin/Denote ./cmd/Denote - go build -o $HOME/bin/Journal ./cmd/Journal + go build -o $HOME/bin/Denote . clean:V: - rm -f $HOME/bin/Denote $HOME/bin/Journal + rm -f $HOME/bin/Denote