ajhahn.de
← eeco
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
}