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