diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b51fb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/Denote +/Journal +TODO.txt \ No newline at end of file diff --git a/cmd/Denote/main.go b/cmd/Denote/main.go new file mode 100644 index 0000000..b4b13eb --- /dev/null +++ b/cmd/Denote/main.go @@ -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) + } +} diff --git a/cmd/Journal/main.go b/cmd/Journal/main.go new file mode 100644 index 0000000..ba565dc --- /dev/null +++ b/cmd/Journal/main.go @@ -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) +} + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f3d1da3 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module denote + +go 1.21 + +require 9fans.net/go v0.0.5 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5089ea2 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/denote/denote.go b/internal/denote/denote.go new file mode 100644 index 0000000..043bec9 --- /dev/null +++ b/internal/denote/denote.go @@ -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 "" +} diff --git a/mkfile b/mkfile new file mode 100644 index 0000000..79efdba --- /dev/null +++ b/mkfile @@ -0,0 +1,6 @@ +install:V: + go build -o $HOME/bin/Denote ./cmd/Denote + go build -o $HOME/bin/Journal ./cmd/Journal + +clean:V: + rm -f $HOME/bin/Denote $HOME/bin/Journal