499 lines
14 KiB
Go
499 lines
14 KiB
Go
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 <identifier>\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 <identifier> 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)
|
|
}
|
|
}
|