// 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 /notes/, named with a UTC // timestamp and a slug derived from the text. The surface is // append + list only: editing is `$EDITOR `, 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 // "-.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 }