Refactoring to a single program, using 9p
This commit is contained in:
parent
4dfa11747f
commit
0cb017f304
|
|
@ -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 <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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -2,4 +2,4 @@ module denote
|
||||||
|
|
||||||
go 1.21
|
go 1.21
|
||||||
|
|
||||||
require 9fans.net/go v0.0.5
|
require 9fans.net/go v0.0.7
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -1,5 +1,5 @@
|
||||||
9fans.net/go v0.0.5 h1:u0H3Et5NowdVm56FDD+aWcDOao9zEo+zolNFsr8qyQk=
|
9fans.net/go v0.0.7 h1:H5CsYJTf99C8EYAQr+uSoEJnLP/iZU8RmDuhyk30iSM=
|
||||||
9fans.net/go v0.0.5/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0=
|
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=
|
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/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=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
|
|
||||||
|
|
@ -1,259 +1,119 @@
|
||||||
package denote
|
package denote
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"denote/internal/fs"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"9fans.net/go/acme"
|
"9fans.net/go/plan9"
|
||||||
|
"9fans.net/go/plan9/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Note struct {
|
// readFile reads entire content from a 9P file
|
||||||
Path string
|
func readFile(f *client.Fsys, path string) (string, error) {
|
||||||
Identifier string
|
fid, err := f.Open(path, plan9.OREAD)
|
||||||
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)
|
|
||||||
if err != nil {
|
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
|
// Search returns metadata for notes by reading from the 9P index
|
||||||
func (f *Filter) Matches(n *Note) bool {
|
func Search(filters []*fs.Filter) (fs.Results, error) {
|
||||||
result := false
|
var results fs.Results
|
||||||
switch f.field {
|
|
||||||
case "date":
|
err := fs.With9P(func(f *client.Fsys) error {
|
||||||
result = f.re.MatchString(n.Identifier)
|
// Read index to get identifiers
|
||||||
case "title":
|
indexContent, err := readFile(f, "index")
|
||||||
title := n.Title
|
if err != nil {
|
||||||
if title == "" {
|
return fmt.Errorf("failed to read index: %w", err)
|
||||||
title = n.FileTitle
|
|
||||||
}
|
}
|
||||||
result = f.re.MatchString(title)
|
|
||||||
case "tag":
|
lines := strings.Split(indexContent, "\n")
|
||||||
for _, kw := range n.Keywords {
|
for _, line := range lines {
|
||||||
if f.re.MatchString(kw) {
|
if line == "" {
|
||||||
result = true
|
continue
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
default: // any field
|
parts := strings.Split(line, "|")
|
||||||
if f.re.MatchString(n.Identifier) {
|
if len(parts) < 1 {
|
||||||
result = true
|
continue
|
||||||
} else {
|
|
||||||
title := n.Title
|
|
||||||
if title == "" {
|
|
||||||
title = n.FileTitle
|
|
||||||
}
|
}
|
||||||
if f.re.MatchString(title) {
|
|
||||||
result = true
|
identifier := strings.TrimSpace(parts[0])
|
||||||
} else {
|
meta := &fs.Metadata{
|
||||||
for _, kw := range n.Keywords {
|
Identifier: identifier,
|
||||||
if f.re.MatchString(kw) {
|
}
|
||||||
result = true
|
|
||||||
break
|
// 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
|
// Read title - 9P server already returns file content title if available,
|
||||||
type SortType int
|
// otherwise filename-based title
|
||||||
|
if title, err := readFile(f, identifier+"/title"); err == nil {
|
||||||
const (
|
meta.Title = title
|
||||||
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
|
|
||||||
}
|
}
|
||||||
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
|
// Apply 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 != "" {
|
|
||||||
match := true
|
match := true
|
||||||
for _, filt := range filters {
|
for _, filt := range filters {
|
||||||
if !filt.Matches(note) {
|
if !filt.IsMatch(meta) {
|
||||||
match = false
|
match = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if match {
|
if match {
|
||||||
notes = append(notes, note)
|
results = append(results, meta)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default sort by ID (reverse chronological)
|
return results, nil
|
||||||
SortNotes(notes, SortByID)
|
|
||||||
|
|
||||||
return notes, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DisplayNotes displays notes in an acme window
|
// Templates for denote frontmatter (org, md, txt)
|
||||||
func DisplayNotes(notes []*Note) error {
|
var templates = map[string]string{
|
||||||
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{
|
|
||||||
"org": `#+title: %s
|
"org": `#+title: %s
|
||||||
#+date: %s
|
#+date: %s
|
||||||
#+filetags: %s
|
#+filetags: %s
|
||||||
#+identifier: %s
|
#+identifier: %s
|
||||||
#+signature: %s
|
|
||||||
|
|
||||||
`,
|
`,
|
||||||
"md-yaml": `---
|
"md-yaml": `---
|
||||||
|
|
@ -261,7 +121,6 @@ title: %s
|
||||||
date: %s
|
date: %s
|
||||||
tags: %s
|
tags: %s
|
||||||
identifier: %s
|
identifier: %s
|
||||||
signature: %s
|
|
||||||
---
|
---
|
||||||
|
|
||||||
`,
|
`,
|
||||||
|
|
@ -270,7 +129,6 @@ title = %s
|
||||||
date = %s
|
date = %s
|
||||||
tags = %s
|
tags = %s
|
||||||
identifier = %s
|
identifier = %s
|
||||||
signature = %s
|
|
||||||
+++
|
+++
|
||||||
|
|
||||||
`,
|
`,
|
||||||
|
|
@ -278,12 +136,13 @@ signature = %s
|
||||||
date: %s
|
date: %s
|
||||||
tags: %s
|
tags: %s
|
||||||
identifier: %s
|
identifier: %s
|
||||||
signature: %s
|
|
||||||
---------------------------
|
---------------------------
|
||||||
|
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileExtensions contains the list of file extension
|
||||||
|
// for which Denote should add front matter.
|
||||||
var FileExtensions = map[string]string{
|
var FileExtensions = map[string]string{
|
||||||
"org": ".org",
|
"org": ".org",
|
||||||
"md-yaml": ".md",
|
"md-yaml": ".md",
|
||||||
|
|
@ -291,104 +150,54 @@ var FileExtensions = map[string]string{
|
||||||
"txt": ".txt",
|
"txt": ".txt",
|
||||||
}
|
}
|
||||||
|
|
||||||
func FormatKeywords(keywords []string, fileType string) string {
|
func formatTags(tags []string, fileType string) string {
|
||||||
if len(keywords) == 0 {
|
if len(tags) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
switch fileType {
|
switch fileType {
|
||||||
case "org":
|
case "org":
|
||||||
return ":" + strings.Join(keywords, ":") + ":"
|
return ":" + strings.Join(tags, ":") + ":"
|
||||||
case "md-yaml", "md-toml":
|
case "md-yaml", "md-toml":
|
||||||
return "[" + strings.Join(keywords, ", ") + "]"
|
return "[" + strings.Join(tags, ", ") + "]"
|
||||||
default:
|
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
|
// Use provided identifier or generate new one
|
||||||
if identifier == "" {
|
if identifier == "" {
|
||||||
identifier = time.Now().Format("20060102T150405")
|
identifier = time.Now().Format("20060102T150405")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format file name
|
// Format file name
|
||||||
titleSlug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
|
titleSlug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
|
||||||
titleSlug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(titleSlug, "")
|
titleSlug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(titleSlug, "")
|
||||||
|
|
||||||
var keywordsPart string
|
var keywordsPart string
|
||||||
if len(keywords) > 0 {
|
if len(keywords) > 0 {
|
||||||
keywordsPart = "__" + strings.Join(keywords, "_")
|
keywordsPart = "__" + strings.Join(keywords, "_")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := FileExtensions[fileType]
|
ext := FileExtensions[fileType]
|
||||||
filename := fmt.Sprintf("%s--%s%s%s", identifier, titleSlug, keywordsPart, ext)
|
filename := fmt.Sprintf("%s--%s%s%s", identifier, titleSlug, keywordsPart, ext)
|
||||||
path := filepath.Join(dir, filename)
|
path := filepath.Join(dir, filename)
|
||||||
|
|
||||||
// Generate front matter
|
// Generate front matter
|
||||||
template := FrontMatterTemplates[fileType]
|
template := templates[fileType]
|
||||||
dateStr := time.Now().Format("2006-01-02 Mon 15:04")
|
dateStr := time.Now().Format("2006-01-02 Mon 15:04")
|
||||||
keywordsStr := FormatKeywords(keywords, fileType)
|
keywordsStr := formatTags(keywords, fileType)
|
||||||
|
|
||||||
content := fmt.Sprintf(template, title, dateStr, keywordsStr, identifier, "")
|
content := fmt.Sprintf(template, title, dateStr, keywordsStr, identifier)
|
||||||
|
|
||||||
// Write file
|
// Write file
|
||||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return path, nil
|
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
|
// FrontMatter represents parsed front matter from a note
|
||||||
type FrontMatter struct {
|
type FrontMatter struct {
|
||||||
Title string
|
Title string
|
||||||
|
|
@ -397,17 +206,17 @@ type FrontMatter struct {
|
||||||
FileType string // org, md-yaml, md-toml, txt
|
FileType string // org, md-yaml, md-toml, txt
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseFrontMatter extracts front matter from a file
|
// ExtractFrontMatter extracts front matter from a file
|
||||||
func ParseFrontMatter(path string) (*FrontMatter, error) {
|
func ExtractFrontMatter(path string) (*FrontMatter, error) {
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
text := string(data)
|
text := string(data)
|
||||||
|
|
||||||
fm := &FrontMatter{}
|
fm := &FrontMatter{}
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".org":
|
case ".org":
|
||||||
fm.FileType = "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 {
|
if m := regexp.MustCompile(`(?m)^#\+identifier:\s*(.+)$`).FindStringSubmatch(text); m != nil {
|
||||||
fm.Identifier = strings.TrimSpace(m[1])
|
fm.Identifier = strings.TrimSpace(m[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
case ".md":
|
case ".md":
|
||||||
// Try YAML first
|
// Try YAML first
|
||||||
yamlRe := regexp.MustCompile(`(?ms)^---\n(.*?)\n---`)
|
yamlRe := regexp.MustCompile(`(?ms)^---\n(.*?)\n---`)
|
||||||
|
|
@ -461,7 +270,7 @@ func ParseFrontMatter(path string) (*FrontMatter, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case ".txt":
|
case ".txt":
|
||||||
fm.FileType = "txt"
|
fm.FileType = "txt"
|
||||||
if m := regexp.MustCompile(`(?m)^title:\s*(.+)$`).FindStringSubmatch(text); m != nil {
|
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])
|
fm.Identifier = strings.TrimSpace(m[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fm, nil
|
return fm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -485,22 +294,21 @@ func UpdateFrontMatter(path string, fm *FrontMatter) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
text := string(data)
|
text := string(data)
|
||||||
|
|
||||||
dateStr := time.Now().Format("2006-01-02 Mon 15:04")
|
dateStr := time.Now().Format("2006-01-02 Mon 15:04")
|
||||||
keywordsStr := FormatKeywords(fm.Tags, fm.FileType)
|
keywordsStr := formatTags(fm.Tags, fm.FileType)
|
||||||
|
|
||||||
var newFrontMatter string
|
// 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
|
var newText string
|
||||||
|
|
||||||
switch fm.FileType {
|
switch fm.FileType {
|
||||||
case "org":
|
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)
|
// Find end of front matter (first blank line or non-#+ line)
|
||||||
lines := strings.Split(text, "\n")
|
lines := strings.Split(text, "\n")
|
||||||
endIdx := 0
|
endIdx := 0
|
||||||
|
|
@ -515,17 +323,8 @@ func UpdateFrontMatter(path string, fm *FrontMatter) error {
|
||||||
} else {
|
} else {
|
||||||
newText = newFrontMatter + text
|
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
|
// Replace YAML front matter
|
||||||
re := regexp.MustCompile(`(?s)^---\n.*?\n---\n*`)
|
re := regexp.MustCompile(`(?s)^---\n.*?\n---\n*`)
|
||||||
if re.MatchString(text) {
|
if re.MatchString(text) {
|
||||||
|
|
@ -533,17 +332,8 @@ signature:
|
||||||
} else {
|
} else {
|
||||||
newText = newFrontMatter + text
|
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
|
// Replace TOML front matter
|
||||||
re := regexp.MustCompile(`(?s)^\+\+\+\n.*?\n\+\+\+\n*`)
|
re := regexp.MustCompile(`(?s)^\+\+\+\n.*?\n\+\+\+\n*`)
|
||||||
if re.MatchString(text) {
|
if re.MatchString(text) {
|
||||||
|
|
@ -551,16 +341,8 @@ signature =
|
||||||
} else {
|
} else {
|
||||||
newText = newFrontMatter + text
|
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
|
// Replace text front matter
|
||||||
re := regexp.MustCompile(`(?s)^title:.*?\n-+\n*`)
|
re := regexp.MustCompile(`(?s)^title:.*?\n-+\n*`)
|
||||||
if re.MatchString(text) {
|
if re.MatchString(text) {
|
||||||
|
|
@ -571,117 +353,108 @@ signature:
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported file type: %s", fm.FileType)
|
return fmt.Errorf("unsupported file type: %s", fm.FileType)
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(path, []byte(newText), 0644)
|
return os.WriteFile(path, []byte(newText), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenameNote renames a note file according to denote convention
|
// Rename renames a note file according to denote convention
|
||||||
func RenameNote(oldPath, title string, keywords []string, identifier string) (string, error) {
|
func Rename(oldPath, title string, keywords []string, identifier string) (string, error) {
|
||||||
if identifier == "" {
|
if identifier == "" {
|
||||||
identifier = time.Now().Format("20060102T150405")
|
identifier = time.Now().Format("20060102T150405")
|
||||||
}
|
}
|
||||||
|
|
||||||
titleSlug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
|
titleSlug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
|
||||||
titleSlug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(titleSlug, "")
|
titleSlug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(titleSlug, "")
|
||||||
|
|
||||||
var keywordsPart string
|
var keywordsPart string
|
||||||
if len(keywords) > 0 {
|
if len(keywords) > 0 {
|
||||||
keywordsPart = "__" + strings.Join(keywords, "_")
|
keywordsPart = "__" + strings.Join(keywords, "_")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := filepath.Ext(oldPath)
|
ext := filepath.Ext(oldPath)
|
||||||
dir := filepath.Dir(oldPath)
|
dir := filepath.Dir(oldPath)
|
||||||
filename := fmt.Sprintf("%s--%s%s%s", identifier, titleSlug, keywordsPart, ext)
|
filename := fmt.Sprintf("%s--%s%s%s", identifier, titleSlug, keywordsPart, ext)
|
||||||
newPath := filepath.Join(dir, filename)
|
newPath := filepath.Join(dir, filename)
|
||||||
|
|
||||||
if err := os.Rename(oldPath, newPath); err != nil {
|
if err := os.Rename(oldPath, newPath); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return newPath, nil
|
return newPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckCryptPutAvailable checks if CryptPut is installed and available
|
// NewNoteEncrypted creates a new encrypted denote note using CryptPut
|
||||||
func CheckCryptPutAvailable() error {
|
func NewNoteEncrypted(dir, title string, keywords []string, fileType, identifier string) (string, error) {
|
||||||
_, err := exec.LookPath("CryptPut")
|
if _, err := exec.LookPath("CryptPut"); err != nil {
|
||||||
if err != nil {
|
fmt.Fprintf(os.Stderr, "CryptPut is not installed.\ngit clone https://github.com/lneely/acme-crypt\n")
|
||||||
return fmt.Errorf("CryptPut is not installed.\ngit clone https://github.com/lneely/acme-crypt")
|
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
|
// Use provided identifier or generate new one
|
||||||
if identifier == "" {
|
if identifier == "" {
|
||||||
identifier = time.Now().Format("20060102T150405")
|
identifier = time.Now().Format("20060102T150405")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format file name (without .gpg extension - CryptPut will add it)
|
// Format file name (without .gpg extension - CryptPut will add it)
|
||||||
titleSlug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
|
titleSlug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
|
||||||
titleSlug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(titleSlug, "")
|
titleSlug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(titleSlug, "")
|
||||||
|
|
||||||
var keywordsPart string
|
var keywordsPart string
|
||||||
if len(keywords) > 0 {
|
if len(keywords) > 0 {
|
||||||
keywordsPart = "__" + strings.Join(keywords, "_")
|
keywordsPart = "__" + strings.Join(keywords, "_")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := FileExtensions[fileType]
|
ext := FileExtensions[fileType]
|
||||||
filename := fmt.Sprintf("%s--%s%s%s", identifier, titleSlug, keywordsPart, ext)
|
filename := fmt.Sprintf("%s--%s%s%s", identifier, titleSlug, keywordsPart, ext)
|
||||||
plainPath := filepath.Join(dir, filename)
|
plainPath := filepath.Join(dir, filename)
|
||||||
|
|
||||||
// Generate front matter content
|
template := templates[fileType]
|
||||||
template := FrontMatterTemplates[fileType]
|
|
||||||
dateStr := time.Now().Format("2006-01-02 Mon 15:04")
|
dateStr := time.Now().Format("2006-01-02 Mon 15:04")
|
||||||
keywordsStr := FormatKeywords(keywords, fileType)
|
keywordsStr := formatTags(keywords, fileType)
|
||||||
|
content := fmt.Sprintf(template, title, dateStr, keywordsStr, identifier)
|
||||||
content := fmt.Sprintf(template, title, dateStr, keywordsStr, identifier, "")
|
|
||||||
|
|
||||||
// Use CryptPut to create encrypted file
|
|
||||||
cmd := exec.Command("CryptPut", plainPath)
|
cmd := exec.Command("CryptPut", plainPath)
|
||||||
cmd.Stdin = strings.NewReader(content)
|
cmd.Stdin = strings.NewReader(content)
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return "", fmt.Errorf("failed to create encrypted file: %v", err)
|
return "", fmt.Errorf("failed to create encrypted file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CryptPut creates the file with .gpg extension
|
|
||||||
encryptedPath := plainPath + ".gpg"
|
encryptedPath := plainPath + ".gpg"
|
||||||
|
|
||||||
return encryptedPath, nil
|
return encryptedPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenFile opens a file using the appropriate method based on file extension
|
// Open opens a file using the appropriate method based on file extension
|
||||||
func OpenFile(filePath string) error {
|
func Open(filePath string) error {
|
||||||
if strings.HasSuffix(strings.ToLower(filePath), ".gpg") {
|
if strings.HasSuffix(strings.ToLower(filePath), ".gpg") {
|
||||||
// Handle .gpg files with CryptGet
|
if _, err := exec.LookPath("CryptGet"); err != nil {
|
||||||
if !CheckCryptGetAvailable() {
|
|
||||||
fmt.Fprintf(os.Stderr, "CryptGet is not installed.\ngit clone https://github.com/lneely/acme-crypt\n")
|
fmt.Fprintf(os.Stderr, "CryptGet is not installed.\ngit clone https://github.com/lneely/acme-crypt\n")
|
||||||
return fmt.Errorf("CryptGet not available")
|
return fmt.Errorf("CryptGet not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("CryptGet", filePath)
|
cmd := exec.Command("CryptGet", filePath)
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
} else {
|
} else {
|
||||||
// Use normal plumb for non-encrypted files
|
|
||||||
cmd := exec.Command("plumb", filePath)
|
cmd := exec.Command("plumb", filePath)
|
||||||
return cmd.Run()
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:<tagname>",
|
||||||
|
// "date:<date>", "title:'<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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue