diff --git a/README.md b/README.md index 728fa3e..f91f2df 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/VERSION b/VERSION index 81fd7ba..eac0a14 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.2.0 \ No newline at end of file +v0.2.1 \ No newline at end of file diff --git a/internal/note/note.go b/internal/note/note.go index d971664..b9be78d 100644 --- a/internal/note/note.go +++ b/internal/note/note.go @@ -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 { @@ -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 diff --git a/internal/note/note_test.go b/internal/note/note_test.go index aaedb6a..bfd4906 100644 --- a/internal/note/note_test.go +++ b/internal/note/note_test.go @@ -3,6 +3,7 @@ package note import ( "os" "path/filepath" + "strings" "testing" "github.com/polymorcodeus/park/internal/config" @@ -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) + } + }) + } +}