Go 157 lines
// Package notes is eeco's free-form workspace scratch surface.
//
// A note is neither a memory fact (frontmatter-strict,
// AI-relevance-matched) nor a queue item (an append-only decision
// channel) — it is a place to scribble. Notes live as one plain
// Markdown file each under <workspace>/notes/, named with a UTC
// timestamp and a slug derived from the text. The surface is
// append + list only: editing is `$EDITOR <file>`, deletion is `rm`.
package notes
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
"unicode"
)
// stampLayout is the UTC timestamp prefix on a note filename. The
// resolution is one second; a slug keeps two notes in the same second
// from colliding in practice.
const stampLayout = "2006-01-02-150405"
// Note is one listed note: the file it lives in, the time it was
// written (parsed from the filename, falling back to mtime), and a
// one-line summary (the first non-blank line of the body).
type Note struct {
Path string
When time.Time
Summary string
}
// Add writes text to a new note file under notesDir, creating the
// directory if missing, and returns the written path. The filename is
// "<stamp>-<slug>.md"; the body is text verbatim. Empty or
// whitespace-only text is rejected.
func Add(notesDir, text string, now time.Time) (string, error) {
if notesDir == "" {
return "", errors.New("notes.Add: notesDir is empty")
}
if strings.TrimSpace(text) == "" {
return "", errors.New("notes.Add: text is empty")
}
if now.IsZero() {
now = time.Now()
}
if err := os.MkdirAll(notesDir, 0o755); err != nil {
return "", err
}
name := now.UTC().Format(stampLayout) + "-" + slug(text) + ".md"
path := filepath.Join(notesDir, name)
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
return "", err
}
return path, nil
}
// List returns the notes under notesDir, newest first. A missing
// directory yields an empty slice and a nil error, mirroring
// queue.Count's missing-file handling.
func List(notesDir string) ([]Note, error) {
entries, err := os.ReadDir(notesDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var out []Note
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
path := filepath.Join(notesDir, e.Name())
out = append(out, Note{
Path: path,
When: noteTime(e, path),
Summary: summary(path),
})
}
sort.Slice(out, func(i, j int) bool {
return out[i].When.After(out[j].When)
})
return out, nil
}
// noteTime parses the timestamp prefix from a note filename, falling
// back to the file's mtime when the name does not carry a parseable
// stamp.
func noteTime(e os.DirEntry, path string) time.Time {
name := strings.TrimSuffix(e.Name(), ".md")
if len(name) >= len(stampLayout) {
if t, err := time.ParseInLocation(stampLayout, name[:len(stampLayout)], time.UTC); err == nil {
return t
}
}
if info, err := e.Info(); err == nil {
return info.ModTime()
}
if info, err := os.Stat(path); err == nil {
return info.ModTime()
}
return time.Time{}
}
// summary returns the first non-blank line of the note body, trimmed.
// A note that is unreadable or all-blank yields an empty summary.
func summary(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
for _, line := range strings.Split(string(b), "\n") {
if s := strings.TrimSpace(line); s != "" {
return s
}
}
return ""
}
// slug reduces a note's text to a short filename-safe stem. Runs of
// non-`[a-z0-9]` collapse to `-`; the result is capped at slugMaxRunes
// runes and trimmed of leading and trailing `-`. An empty or
// all-punctuation note yields the fallback "note".
func slug(text string) string {
const slugMaxRunes = 30
var b strings.Builder
dash := false
count := 0
for _, r := range text {
if count >= slugMaxRunes {
break
}
lr := unicode.ToLower(r)
if (lr >= 'a' && lr <= 'z') || (lr >= '0' && lr <= '9') {
b.WriteRune(lr)
dash = false
count++
continue
}
if !dash && b.Len() > 0 {
b.WriteRune('-')
dash = true
count++
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "note"
}
return out
}