Initial commit: Denote and Journal for acme
This commit is contained in:
parent
084f896ad6
commit
041bb6a2da
|
|
@ -0,0 +1,3 @@
|
||||||
|
/Denote
|
||||||
|
/Journal
|
||||||
|
TODO.txt
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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] '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
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
new 'My Document Title' tag1 tag2
|
||||||
|
new -f org 'Meeting Notes' meeting project
|
||||||
|
new -f txt 'Journal Entry' journal
|
||||||
|
|
||||||
|
Note: Title must be quoted with single quotes.
|
||||||
|
`)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileType := "md-yaml"
|
||||||
|
|
||||||
|
// Check for -f flag
|
||||||
|
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] '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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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] '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
|
||||||
|
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 main() {
|
||||||
|
dir := flag.String("dir", denote.DefaultDir(), "denote directory")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Split args on whitespace to handle acme mouse chording
|
||||||
|
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...] List notes matching filters
|
||||||
|
Denote new 'Title' [tags...] Create a new note
|
||||||
|
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)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
Denote List all notes
|
||||||
|
Denote date:/2025.*/ Notes from 2025
|
||||||
|
Denote tag:meeting Notes tagged 'meeting'
|
||||||
|
Denote date:/202510.*/ !tag:journal October 2025, not journal
|
||||||
|
Denote new 'My Note' tag1 tag2 Create new note
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-dir path Denote directory (default: $DENOTE_DIR or ~/doc)
|
||||||
|
|
||||||
|
For 'new' command help: Denote new
|
||||||
|
`)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a "new" command
|
||||||
|
if len(filterArgs) > 0 && filterArgs[0] == "new" {
|
||||||
|
handleNew(*dir, filterArgs[1:])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var filters []*denote.Filter
|
||||||
|
for _, arg := range filterArgs {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := denote.DisplayNotes(notes); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"denote/internal/denote"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 main() {
|
||||||
|
fileType := flag.String("f", "md-yaml", "file type (org, md-yaml, md-toml, txt)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
args := flag.Args()
|
||||||
|
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()
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
// Journal - find or create today's entry
|
||||||
|
dateFilter, _ := denote.ParseFilter(fmt.Sprintf("date:%s", now.Format("20060102")))
|
||||||
|
tagFilter, _ := denote.ParseFilter("tag:journal")
|
||||||
|
|
||||||
|
notes, err := denote.FindNotes(dir, []*denote.Filter{dateFilter, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new entry for today
|
||||||
|
title := formatJournalTitle(now)
|
||||||
|
path, err := denote.CreateNote(dir, title, []string{"journal"}, *fileType, "")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if args[0] == "add" {
|
||||||
|
// Journal add [YYYYMMDD]
|
||||||
|
var targetDate time.Time
|
||||||
|
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")
|
||||||
|
} else {
|
||||||
|
targetDate = now
|
||||||
|
identifier = now.Format("20060102T150405")
|
||||||
|
}
|
||||||
|
|
||||||
|
title := formatJournalTitle(targetDate)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage:\n Journal Find or create today's entry\n Journal add Create additional entry for today\n Journal add DATE Create entry for specific date (YYYYMMDD)\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
9fans.net/go v0.0.5 h1:u0H3Et5NowdVm56FDD+aWcDOao9zEo+zolNFsr8qyQk=
|
||||||
|
9fans.net/go v0.0.5/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0=
|
||||||
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
|
||||||
|
golang.org/x/exp v0.0.0-20210405174845-4513512abef3/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4=
|
||||||
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||||
|
golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
|
||||||
|
golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
|
||||||
|
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
package denote
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"9fans.net/go/acme"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Note struct {
|
||||||
|
Path string
|
||||||
|
Identifier string
|
||||||
|
Signature string
|
||||||
|
Title string
|
||||||
|
Keywords []string
|
||||||
|
Extension string
|
||||||
|
FileTitle string
|
||||||
|
}
|
||||||
|
|
||||||
|
var identifierRe = regexp.MustCompile(`^(\d{8}T\d{6})`)
|
||||||
|
var signatureRe = regexp.MustCompile(`==([^-_\.]+)`)
|
||||||
|
var titleRe = regexp.MustCompile(`--([^_\.]+)`)
|
||||||
|
var keywordsRe = regexp.MustCompile(`__(.+?)(?:\.|$)`)
|
||||||
|
|
||||||
|
// ParseNote parses a denote filename into a Note struct
|
||||||
|
func ParseNote(path string) *Note {
|
||||||
|
name := filepath.Base(path)
|
||||||
|
note := &Note{Path: path}
|
||||||
|
|
||||||
|
if m := identifierRe.FindStringSubmatch(name); m != nil {
|
||||||
|
note.Identifier = m[1]
|
||||||
|
}
|
||||||
|
if m := signatureRe.FindStringSubmatch(name); m != nil {
|
||||||
|
note.Signature = m[1]
|
||||||
|
}
|
||||||
|
if m := titleRe.FindStringSubmatch(name); m != nil {
|
||||||
|
note.Title = strings.ReplaceAll(m[1], "-", " ")
|
||||||
|
}
|
||||||
|
if m := keywordsRe.FindStringSubmatch(name); m != nil {
|
||||||
|
note.Keywords = strings.Split(m[1], "_")
|
||||||
|
}
|
||||||
|
note.Extension = filepath.Ext(name)
|
||||||
|
note.FileTitle = ExtractTitle(path)
|
||||||
|
|
||||||
|
return note
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter represents a search filter
|
||||||
|
type Filter struct {
|
||||||
|
field string // "date", "title", "tag", or "" for any
|
||||||
|
re *regexp.Regexp
|
||||||
|
negate bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFilter parses a filter string into a Filter
|
||||||
|
func ParseFilter(arg string) (*Filter, error) {
|
||||||
|
negate := strings.HasPrefix(arg, "!")
|
||||||
|
if negate {
|
||||||
|
arg = strings.TrimPrefix(arg, "!")
|
||||||
|
}
|
||||||
|
|
||||||
|
m := regexp.MustCompile(`^(?:(date|title|tag):)?(.+)$`).FindStringSubmatch(arg)
|
||||||
|
if m == nil {
|
||||||
|
return nil, fmt.Errorf("invalid filter syntax: %s", arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern := m[2]
|
||||||
|
if strings.HasPrefix(pattern, "/") && strings.HasSuffix(pattern, "/") {
|
||||||
|
pattern = strings.TrimPrefix(strings.TrimSuffix(pattern, "/"), "/")
|
||||||
|
} else {
|
||||||
|
pattern = regexp.QuoteMeta(pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid regex: %v", err)
|
||||||
|
}
|
||||||
|
return &Filter{field: m[1], re: re, negate: negate}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches checks if a note matches this filter
|
||||||
|
func (f *Filter) Matches(n *Note) bool {
|
||||||
|
result := false
|
||||||
|
switch f.field {
|
||||||
|
case "date":
|
||||||
|
result = f.re.MatchString(n.Identifier)
|
||||||
|
case "title":
|
||||||
|
title := n.Title
|
||||||
|
if title == "" {
|
||||||
|
title = n.FileTitle
|
||||||
|
}
|
||||||
|
result = f.re.MatchString(title)
|
||||||
|
case "tag":
|
||||||
|
for _, kw := range n.Keywords {
|
||||||
|
if f.re.MatchString(kw) {
|
||||||
|
result = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: // any field
|
||||||
|
if f.re.MatchString(n.Identifier) {
|
||||||
|
result = true
|
||||||
|
} else {
|
||||||
|
title := n.Title
|
||||||
|
if title == "" {
|
||||||
|
title = n.FileTitle
|
||||||
|
}
|
||||||
|
if f.re.MatchString(title) {
|
||||||
|
result = true
|
||||||
|
} else {
|
||||||
|
for _, kw := range n.Keywords {
|
||||||
|
if f.re.MatchString(kw) {
|
||||||
|
result = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.negate {
|
||||||
|
return !result
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindNotes searches for notes in a directory matching filters
|
||||||
|
func FindNotes(dir string, filters []*Filter) ([]*Note, error) {
|
||||||
|
var notes []*Note
|
||||||
|
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if note := ParseNote(path); note.Identifier != "" {
|
||||||
|
match := true
|
||||||
|
for _, filt := range filters {
|
||||||
|
if !filt.Matches(note) {
|
||||||
|
match = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
notes = append(notes, note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(notes, func(i, j int) bool {
|
||||||
|
return notes[i].Identifier > notes[j].Identifier
|
||||||
|
})
|
||||||
|
|
||||||
|
return notes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayNotes displays notes in an acme window
|
||||||
|
func DisplayNotes(notes []*Note) error {
|
||||||
|
w, err := acme.New()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating acme window: %v", err)
|
||||||
|
}
|
||||||
|
defer w.CloseFiles()
|
||||||
|
|
||||||
|
w.Name("+Denote")
|
||||||
|
w.Ctl("clean")
|
||||||
|
w.Clear()
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
for _, n := range notes {
|
||||||
|
title := n.FileTitle
|
||||||
|
if title == "" {
|
||||||
|
title = n.Title
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = "(untitled)"
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.WriteString(title)
|
||||||
|
if len(n.Keywords) > 0 {
|
||||||
|
fmt.Fprintf(&buf, " (%s)", strings.Join(n.Keywords, ", "))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&buf, "\n%s\n\n", n.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
#+date: %s
|
||||||
|
#+filetags: %s
|
||||||
|
#+identifier: %s
|
||||||
|
#+signature: %s
|
||||||
|
|
||||||
|
`,
|
||||||
|
"md-yaml": `---
|
||||||
|
title: %s
|
||||||
|
date: %s
|
||||||
|
tags: %s
|
||||||
|
identifier: %s
|
||||||
|
signature: %s
|
||||||
|
---
|
||||||
|
|
||||||
|
`,
|
||||||
|
"md-toml": `+++
|
||||||
|
title = %s
|
||||||
|
date = %s
|
||||||
|
tags = %s
|
||||||
|
identifier = %s
|
||||||
|
signature = %s
|
||||||
|
+++
|
||||||
|
|
||||||
|
`,
|
||||||
|
"txt": `title: %s
|
||||||
|
date: %s
|
||||||
|
tags: %s
|
||||||
|
identifier: %s
|
||||||
|
signature: %s
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
var FileExtensions = map[string]string{
|
||||||
|
"org": ".org",
|
||||||
|
"md-yaml": ".md",
|
||||||
|
"md-toml": ".md",
|
||||||
|
"txt": ".txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatKeywords(keywords []string, fileType string) string {
|
||||||
|
if len(keywords) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch fileType {
|
||||||
|
case "org":
|
||||||
|
return ":" + strings.Join(keywords, ":") + ":"
|
||||||
|
case "md-yaml", "md-toml":
|
||||||
|
return "[" + strings.Join(keywords, ", ") + "]"
|
||||||
|
default:
|
||||||
|
return strings.Join(keywords, " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateNote(dir, title string, keywords []string, fileType, identifier string) (string, error) {
|
||||||
|
// Use provided identifier or generate new one
|
||||||
|
if identifier == "" {
|
||||||
|
identifier = time.Now().Format("20060102T150405")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format file name
|
||||||
|
titleSlug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
|
||||||
|
titleSlug = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(titleSlug, "")
|
||||||
|
|
||||||
|
var keywordsPart string
|
||||||
|
if len(keywords) > 0 {
|
||||||
|
keywordsPart = "__" + strings.Join(keywords, "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := FileExtensions[fileType]
|
||||||
|
filename := fmt.Sprintf("%s--%s%s%s", identifier, titleSlug, keywordsPart, ext)
|
||||||
|
path := filepath.Join(dir, filename)
|
||||||
|
|
||||||
|
// Generate front matter
|
||||||
|
template := FrontMatterTemplates[fileType]
|
||||||
|
dateStr := time.Now().Format("2006-01-02 Mon 15:04")
|
||||||
|
keywordsStr := FormatKeywords(keywords, fileType)
|
||||||
|
|
||||||
|
content := fmt.Sprintf(template, title, dateStr, keywordsStr, identifier, "")
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultDir() string {
|
||||||
|
if d := os.Getenv("DENOTE_DIR"); d != "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
if d := os.Getenv("XDG_DOCUMENTS_DIR"); d != "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return filepath.Join(os.Getenv("HOME"), "doc")
|
||||||
|
}
|
||||||
|
|
||||||
|
var orgTitleRe = regexp.MustCompile(`(?m)^#\+title:\s*(.+)$`)
|
||||||
|
var mdYamlTitleRe = regexp.MustCompile(`(?ms)^---\n.*?^title:\s*(.+?)$.*?^---`)
|
||||||
|
var mdHeaderRe = regexp.MustCompile(`(?m)^#\s+(.+)$`)
|
||||||
|
|
||||||
|
func ExtractTitle(path string) string {
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
if ext != ".org" && ext != ".md" && ext != ".txt" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
|
||||||
|
// Try org-mode #+title: first, then fall back to first heading
|
||||||
|
if ext == ".org" {
|
||||||
|
if m := orgTitleRe.FindStringSubmatch(text); m != nil {
|
||||||
|
return strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
// Fallback to first heading (lines starting with *)
|
||||||
|
if m := regexp.MustCompile(`(?m)^\*+\s+(.+)$`).FindStringSubmatch(text); m != nil {
|
||||||
|
return strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try markdown YAML front matter title: first, then fall back to # header
|
||||||
|
if ext == ".md" {
|
||||||
|
if m := mdYamlTitleRe.FindStringSubmatch(text); m != nil {
|
||||||
|
return strings.TrimSpace(strings.Trim(m[1], `"`))
|
||||||
|
}
|
||||||
|
if m := mdHeaderRe.FindStringSubmatch(text); m != nil {
|
||||||
|
return strings.TrimSpace(m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue