Initial commit: Denote and Journal for acme

This commit is contained in:
Levi Neely 2025-11-03 10:34:18 +01:00
parent 084f896ad6
commit 041bb6a2da
7 changed files with 659 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/Denote
/Journal
TODO.txt

153
cmd/Denote/main.go Normal file
View File

@ -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)
}
}

114
cmd/Journal/main.go Normal file
View File

@ -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)
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module denote
go 1.21
require 9fans.net/go v0.0.5

34
go.sum Normal file
View File

@ -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=

344
internal/denote/denote.go Normal file
View File

@ -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 ""
}

6
mkfile Normal file
View File

@ -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