Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,15 +246,6 @@ default_category = "inbox"

Categories are fully configurable: name, storage path, and TUI hotkey. Add or remove categories to match your workflow. `default_category` is where `park new` lands notes when `-c` is omitted.

## Design

Core decisions that shape the project:

- **Composable architecture.** The six `internal/*` packages (`config`, `note`, `store`, `render`, `model`, `theme`, `fs`) are self-contained with minimal coupling. Importing `internal/model` into another CLI is just wiring commands to exported functions — `cmd/park` exists solely as a reference consumer.
- **No YAML dependency for frontmatter.** Parsed line-by-line as flat `key: value` pairs. One less dependency, zero ambiguity about which YAML dialect.
- **Atomic reclassification.** Frontmatter is rewritten before the file is moved. A failed rename doesn't corrupt state.
- **Synopsis-first triage.** The frontmatter `synopsis` field is the entire inbox UX. No metadata-surfing required.

## Tech stack

| component | library |
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.2.0
v0.2.1
26 changes: 25 additions & 1 deletion internal/note/note.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ func IngestFile(cfg *config.Config, srcPath, filename, synopsis, source, targetC

func slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s, _ = strings.CutSuffix(s, ".md")
var b strings.Builder
lastDash := false
for _, r := range s {
Expand Down Expand Up @@ -352,11 +353,34 @@ func AddNote(cfg *config.Config, in NoteInput) (NoteOutcome, error) {
}

hasInput := in.Body != "" || in.FromFile != ""
hasMetadata := fieldSet(in.Filename) && fieldSet(in.Synopsis) && fieldSet(in.Source)

if hasInput && hasMetadata {
// Caller supplied all required metadata and a body; create the note
// directly. Strip any frontmatter in the body so it isn't duplicated
// by WriteFrontmatter; if there is no frontmatter, keep the body as-is.
body := in.Body
if _, parsed, hasFM := ParseFrontmatterString(in.Body); hasFM {
body = parsed
}
var path string
var err error
if in.FromFile != "" {
path, err = IngestFile(cfg, in.FromFile, in.Filename, in.Synopsis, in.Source, target, body)
} else {
path, err = NewWithBody(cfg, in.Filename, in.Synopsis, in.Source, target, body)
}
if err != nil {
return NoteOutcome{}, err
}
return NoteOutcome{Path: path}, nil
}

if hasInput {
return addFromInput(cfg, in, target)
}

if fieldSet(in.Filename) && fieldSet(in.Synopsis) && fieldSet(in.Source) {
if hasMetadata {
path, err := NewWithBody(cfg, in.Filename, in.Synopsis, in.Source, target, "")
if err != nil {
return NoteOutcome{}, err
Expand Down
195 changes: 195 additions & 0 deletions internal/note/note_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package note
import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/polymorcodeus/park/internal/config"
Expand Down Expand Up @@ -300,3 +301,197 @@ func TestExtractH1(t *testing.T) {
})
}
}

func TestAddNote(t *testing.T) {
tmp := t.TempDir()
cfg := config.DefaultConfig(tmp)
for i := range cfg.Categories {
if err := os.MkdirAll(cfg.Categories[i].Path, 0o755); err != nil {
t.Fatalf("create category folder: %v", err)
}
}

t.Run("all metadata no body creates note", func(t *testing.T) {
in := NoteInput{
Filename: "test-note",
Synopsis: "a test",
Source: "terminal",
Category: "inbox",
}
out, err := AddNote(cfg, in)
if err != nil {
t.Fatalf("AddNote() error = %v", err)
}
if out.Form != nil {
t.Fatal("expected direct creation, got form")
}
if out.Path == "" {
t.Fatal("expected path, got empty")
}
fm, _, err := ParseFrontmatter(out.Path)
if err != nil {
t.Fatalf("ParseFrontmatter() error = %v", err)
}
if fm.Synopsis != "a test" || fm.Source != "terminal" || fm.Category != "inbox" {
t.Errorf("frontmatter mismatch: %+v", fm)
}
})

t.Run("file with all metadata creates directly", func(t *testing.T) {
src := filepath.Join(tmp, "body.md")
content := "## Body\n\ncontent\n"
if err := os.WriteFile(src, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
in := NoteInput{
Filename: "from-file",
Synopsis: "from file",
Source: "migration",
Category: "archive",
Body: content,
FromFile: src,
}
out, err := AddNote(cfg, in)
if err != nil {
t.Fatalf("AddNote() error = %v", err)
}
if out.Form != nil {
t.Fatal("expected direct creation, got form")
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Errorf("source file was not removed")
}
fm, body, err := ParseFrontmatter(out.Path)
if err != nil {
t.Fatalf("ParseFrontmatter() error = %v", err)
}
if fm.Category != "archive" || fm.Synopsis != "from file" || fm.Source != "migration" {
t.Errorf("frontmatter mismatch: %+v", fm)
}
if body != content {
t.Errorf("body = %q, want %q", body, content)
}
})

t.Run("stdin body without frontmatter and all metadata creates directly", func(t *testing.T) {
in := NoteInput{
Filename: "piped",
Synopsis: "piped body",
Source: "stdin",
Category: "projects",
Body: "# Piped title\n\ntext\n",
}
out, err := AddNote(cfg, in)
if err != nil {
t.Fatalf("AddNote() error = %v", err)
}
if out.Form != nil {
t.Fatal("expected direct creation, got form")
}
fm, body, err := ParseFrontmatter(out.Path)
if err != nil {
t.Fatalf("ParseFrontmatter() error = %v", err)
}
if fm.Category != "projects" {
t.Errorf("category = %q, want projects", fm.Category)
}
if body != "# Piped title\n\ntext\n" {
t.Errorf("body = %q, want %q", body, "# Piped title\n\ntext\n")
}
})

t.Run("body with frontmatter uses frontmatter values", func(t *testing.T) {
in := NoteInput{
Body: "---\ncategory: areas\nsource: chat\nsynopsis: fm-driven\ncreated: 2026-07-01\n---\n\n# Title\n\nbody\n",
}
out, err := AddNote(cfg, in)
if err != nil {
t.Fatalf("AddNote() error = %v", err)
}
if out.Form != nil {
t.Fatal("expected direct creation, got form")
}
fm, _, err := ParseFrontmatter(out.Path)
if err != nil {
t.Fatalf("ParseFrontmatter() error = %v", err)
}
if fm.Category != "areas" || fm.Source != "chat" || fm.Synopsis != "fm-driven" {
t.Errorf("frontmatter mismatch: %+v", fm)
}
})

t.Run("body with frontmatter and explicit metadata uses explicit metadata", func(t *testing.T) {
in := NoteInput{
Filename: "explicit",
Synopsis: "explicit",
Source: "explicit",
Category: "archive",
Body: "---\ncategory: areas\nsource: chat\nsynopsis: fm-driven\ncreated: 2026-07-01\n---\n\n# Title\n\nbody\n",
}
out, err := AddNote(cfg, in)
if err != nil {
t.Fatalf("AddNote() error = %v", err)
}
if out.Form != nil {
t.Fatal("expected direct creation, got form")
}
fm, body, err := ParseFrontmatter(out.Path)
if err != nil {
t.Fatalf("ParseFrontmatter() error = %v", err)
}
if fm.Category != "archive" || fm.Source != "explicit" || fm.Synopsis != "explicit" {
t.Errorf("frontmatter mismatch: %+v", fm)
}
if strings.Contains(body, "fm-driven") {
t.Errorf("body still contains old frontmatter")
}
})

t.Run("missing metadata without body returns form", func(t *testing.T) {
in := NoteInput{Filename: "only-filename"}
out, err := AddNote(cfg, in)
if err != nil {
t.Fatalf("AddNote() error = %v", err)
}
if out.Form == nil {
t.Fatal("expected form outcome")
}
})

t.Run("body with incomplete frontmatter returns error", func(t *testing.T) {
in := NoteInput{
Body: "---\ncategory: inbox\n---\n\nbody\n",
}
_, err := AddNote(cfg, in)
if err == nil {
t.Fatal("expected error for incomplete frontmatter")
}
})
}

func TestSlugify(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"plain", "Book Todo Legacy", "book-todo-legacy"},
{"already slug", "book-todo-legacy", "book-todo-legacy"},
{"with markdown extension", "book-todo-legacy.md", "book-todo-legacy"},
{"with uppercase markdown extension", "book-todo-legacy.MD", "book-todo-legacy"},
{"mixed case extension", "book-todo-legacy.Md", "book-todo-legacy"},
{"with date", "Book Todo Legacy 2026-08-06", "book-todo-legacy-2026-08-06"},
{"with date and extension", "Book Todo Legacy 2026-08-06.md", "book-todo-legacy-2026-08-06"},
{"trailing spaces", " book-todo-legacy.md ", "book-todo-legacy"},
{"empty becomes empty", "", ""},
{"only extension", ".md", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := slugify(tt.in)
if got != tt.want {
t.Errorf("slugify(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
Loading