From d4e49431e9323d12ba891ba45846626baecd7ecb Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sun, 13 Sep 2026 13:12:44 -0500 Subject: [PATCH 1/4] bug: code cleanup and convention standards --- README.md | 2 + cmd/park/main.go | 8 +-- cmd/park/main_test.go | 35 +++++++++++ cmd/park/park.go | 8 +-- internal/config/config.go | 31 ++++------ internal/config/config_test.go | 37 ++++++------ internal/model/assist.go | 46 ++++++++++----- internal/model/form.go | 8 +-- internal/model/tui.go | 4 +- internal/note/note.go | 27 ++++----- internal/note/note_test.go | 29 ++++++++++ internal/store/list.go | 2 +- internal/store/store.go | 58 ++++++++++--------- internal/store/store_test.go | 103 +++++++++++++++++++++++++++++++++ 14 files changed, 283 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 140f659..4825432 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,8 @@ The JSON output includes the canonical category enum, field kinds, the date form `park reclassify -c ` rewrites frontmatter *before* moving the file. A failed move never leaves a note in a half-updated state. Same-category moves are rejected. +`` follows the same resolution rules as `park show`: a bare basename is searched across every configured category folder (archive included), or a path (absolute, or relative to the working directory) is used as-is. A value containing a path separator is treated as a literal path and is never joined onto a category folder, so passing a path cannot double-join into `category/path/to/file.md`. + ## Commands | command | purpose | diff --git a/cmd/park/main.go b/cmd/park/main.go index 8b4114e..1e76239 100644 --- a/cmd/park/main.go +++ b/cmd/park/main.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "os" - "strings" "charm.land/lipgloss/v2" "github.com/polymorcodeus/park/internal/config" @@ -180,7 +179,7 @@ func newCommand() *cli.Command { Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { for _, name := range cmd.StringSlice("category") { if !cfg.HasCategory(name) { - return ctx, styledExit(fmt.Errorf("unknown category %q; valid: %s", name, strings.Join(cfg.CategoryNames(), ", ")), 2) + return ctx, styledExit(cfg.UnknownCategoryError(name), 2) } } return ctx, nil @@ -231,8 +230,7 @@ func newCommand() *cli.Command { Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { if newCategory != "" { if !cfg.HasCategory(newCategory) { - err := fmt.Errorf("--category must be one of %s (got %q)", strings.Join(cfg.CategoryNames(), ", "), newCategory) - return ctx, styledExit(err, 1) + return ctx, styledExit(cfg.UnknownCategoryError(newCategory), 1) } } return ctx, nil @@ -263,7 +261,7 @@ func newCommand() *cli.Command { return ctx, styledExit(fmt.Errorf("usage: park reclassify --category "), 2) } if !cfg.HasCategory(reclassifyCategory) { - return ctx, styledExit(fmt.Errorf("unknown category %q; valid: %s", reclassifyCategory, strings.Join(cfg.CategoryNames(), ", ")), 2) + return ctx, styledExit(cfg.UnknownCategoryError(reclassifyCategory), 2) } return ctx, nil }, diff --git a/cmd/park/main_test.go b/cmd/park/main_test.go index 024751d..5a65e66 100644 --- a/cmd/park/main_test.go +++ b/cmd/park/main_test.go @@ -122,6 +122,41 @@ func TestReclassifySameCategory(t *testing.T) { } } +func TestReclassifyAcceptsLiteralPath(t *testing.T) { + root := t.TempDir() + if _, _, err := runPark(t, root, "init"); err != nil { + t.Fatalf("init error = %v", err) + } + notePath := filepath.Join(root, "_inbox", "path-note.md") + writeNote(t, filepath.Join(root, "_inbox"), "path-note.md", "inbox", "a path note") + + if _, _, err := runPark(t, root, "reclassify", notePath, "--category", "projects"); err != nil { + t.Fatalf("reclassify by literal path error = %v", err) + } + if _, err := os.Stat(filepath.Join(root, "_projects", "path-note.md")); err != nil { + t.Errorf("file missing in projects: %v", err) + } + if _, err := os.Stat(notePath); !os.IsNotExist(err) { + t.Errorf("file still exists in inbox: %v", err) + } +} + +func TestReclassifyAcceptsRelativePath(t *testing.T) { + root := t.TempDir() + if _, _, err := runPark(t, root, "init"); err != nil { + t.Fatalf("init error = %v", err) + } + writeNote(t, filepath.Join(root, "_inbox"), "rel-note.md", "inbox", "a relative note") + + t.Chdir(root) + if _, _, err := runPark(t, root, "reclassify", filepath.Join("_inbox", "rel-note.md"), "--category", "areas"); err != nil { + t.Fatalf("reclassify by relative path error = %v", err) + } + if _, err := os.Stat(filepath.Join(root, "_areas", "rel-note.md")); err != nil { + t.Errorf("file missing in areas: %v", err) + } +} + func TestShowMissingArg(t *testing.T) { _, _, err := runPark(t, t.TempDir(), "show") if err == nil { diff --git a/cmd/park/park.go b/cmd/park/park.go index 831727a..47692a2 100644 --- a/cmd/park/park.go +++ b/cmd/park/park.go @@ -124,11 +124,9 @@ func runNoteForm(cfg *config.Config, w io.Writer, seed *note.Draft) error { // a short human-readable summary. func schemaPark(asJSON bool, w io.Writer) error { if asJSON { - data, err := json.MarshalIndent(schema.Describe(), "", " ") - if err != nil { - return fmt.Errorf("marshal schema: %w", err) - } - if _, err := fmt.Fprintln(w, string(data)); err != nil { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(schema.Describe()); err != nil { return fmt.Errorf("write schema output: %w", err) } return nil diff --git a/internal/config/config.go b/internal/config/config.go index 28a2e54..2876169 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -60,7 +60,7 @@ func (c *Config) LoadConfig(root, configPath string) error { } // Validate checks that the config is well-formed. -func (c Config) Validate() error { +func (c *Config) Validate() error { if c.DefaultCategory == "" { return fmt.Errorf("default_category is required") } @@ -90,7 +90,7 @@ func (c Config) Validate() error { } // HasCategory reports whether a category with the given name exists. -func (c Config) HasCategory(name string) bool { +func (c *Config) HasCategory(name string) bool { for _, cl := range c.Categories { if cl.Name == name { return true @@ -100,7 +100,7 @@ func (c Config) HasCategory(name string) bool { } // CategoryByName returns the category with the given name, or zero value if not found. -func (c Config) CategoryByName(name string) (Category, bool) { +func (c *Config) CategoryByName(name string) (Category, bool) { for _, cl := range c.Categories { if cl.Name == name { return cl, true @@ -109,18 +109,8 @@ func (c Config) CategoryByName(name string) (Category, bool) { return Category{}, false } -// CategoryByKey returns the category with the given hotkey, or zero value if not found. -func (c Config) CategoryByKey(key string) (Category, bool) { - for _, cl := range c.Categories { - if cl.Key == key { - return cl, true - } - } - return Category{}, false -} - // CategoryNames returns all category names in order. -func (c Config) CategoryNames() []string { +func (c *Config) CategoryNames() []string { names := make([]string, len(c.Categories)) for i, cl := range c.Categories { names[i] = cl.Name @@ -128,8 +118,14 @@ func (c Config) CategoryNames() []string { return names } +// UnknownCategoryError is the single constructor for "unknown category" +// errors, so every command and package reports the same message. +func (c *Config) UnknownCategoryError(name string) error { + return fmt.Errorf("unknown category %q; valid: %s", name, strings.Join(c.CategoryNames(), ", ")) +} + // Dump returns the config as a TOML string. -func (c Config) Dump() (string, error) { +func (c *Config) Dump() (string, error) { var b strings.Builder enc := toml.NewEncoder(&b) if err := enc.Encode(c); err != nil { @@ -138,11 +134,6 @@ func (c Config) Dump() (string, error) { return b.String(), nil } -// DefaultConfigPath returns the default path to the park configuration file. -func DefaultConfigPath() string { - return DefaultConfigPathFor(DefaultRootPath()) -} - // DefaultConfigPathFor returns the default configuration path under the given // root. An empty root falls back to DefaultRootPath(). func DefaultConfigPathFor(root string) string { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 26da79b..d0eaeaa 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -120,27 +120,27 @@ func TestCategoryByName(t *testing.T) { } } -func TestCategoryByKey(t *testing.T) { +func TestCategoryNames(t *testing.T) { cfg := Config{ - DefaultCategory: "inbox", Categories: []Category{ - {Name: "inbox", Key: "i"}, - {Name: "projects", Key: "p"}, + {Name: "inbox"}, + {Name: "projects"}, }, } - cat, ok := cfg.CategoryByKey("p") - if !ok || cat.Name != "projects" { - t.Errorf("CategoryByKey(p) = %+v, %v; want projects", cat, ok) + got := cfg.CategoryNames() + want := []string{"inbox", "projects"} + if len(got) != len(want) { + t.Fatalf("CategoryNames() = %v, want %v", got, want) } - - _, ok = cfg.CategoryByKey("z") - if ok { - t.Error("CategoryByKey(z) found unexpected category") + for i := range want { + if got[i] != want[i] { + t.Errorf("CategoryNames()[%d] = %q, want %q", i, got[i], want[i]) + } } } -func TestCategoryNames(t *testing.T) { +func TestUnknownCategoryError(t *testing.T) { cfg := Config{ Categories: []Category{ {Name: "inbox"}, @@ -148,14 +148,13 @@ func TestCategoryNames(t *testing.T) { }, } - got := cfg.CategoryNames() - want := []string{"inbox", "projects"} - if len(got) != len(want) { - t.Fatalf("CategoryNames() = %v, want %v", got, want) + err := cfg.UnknownCategoryError("nope") + if err == nil { + t.Fatal("expected non-nil error") } - for i := range want { - if got[i] != want[i] { - t.Errorf("CategoryNames()[%d] = %q, want %q", i, got[i], want[i]) + for _, want := range []string{`unknown category "nope"`, "inbox", "projects"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("UnknownCategoryError() = %q, want substring %q", err.Error(), want) } } } diff --git a/internal/model/assist.go b/internal/model/assist.go index e7fdf83..7303bf2 100644 --- a/internal/model/assist.go +++ b/internal/model/assist.go @@ -55,12 +55,19 @@ func humanAge(t time.Time) string { } } +// categoryBinding pairs a category name with the key binding that both +// renders it in help and matches it in Update, so the two can never drift. +type categoryBinding struct { + name string + binding key.Binding +} + // keyMap defines the key bindings for the park TUI. type keyMap struct { View key.Binding CycleNext key.Binding CyclePrev key.Binding - CategoryBindings []key.Binding + CategoryBindings []categoryBinding Help key.Binding Quit key.Binding } @@ -72,7 +79,11 @@ func (k keyMap) Primary() []key.Binding { // Categories returns reclassify/category action shortcuts shown only in full help. func (k keyMap) Categories() []key.Binding { - return k.CategoryBindings + bindings := make([]key.Binding, len(k.CategoryBindings)) + for i, cb := range k.CategoryBindings { + bindings[i] = cb.binding + } + return bindings } func (k keyMap) ShortHelp() []key.Binding { @@ -113,15 +124,18 @@ var keys = keyMap{ // configured category that has a non-empty Key. func newKeyMap(cfg *config.Config) keyMap { km := keys - km.CategoryBindings = make([]key.Binding, 0, len(cfg.Categories)) + km.CategoryBindings = make([]categoryBinding, 0, len(cfg.Categories)) for _, cl := range cfg.Categories { if cl.Key == "" { continue } - km.CategoryBindings = append(km.CategoryBindings, key.NewBinding( - key.WithKeys(cl.Key), - key.WithHelp(cl.Key, cl.Name), - )) + km.CategoryBindings = append(km.CategoryBindings, categoryBinding{ + name: cl.Name, + binding: key.NewBinding( + key.WithKeys(cl.Key), + key.WithHelp(cl.Key, cl.Name), + ), + }) } return km } @@ -160,8 +174,8 @@ func NewAssistModel(cfg *config.Config) (AssistModel, error) { keys: newKeyMap(cfg), help: help.New(), styles: s, - list: list.New(nil, delegate, minWidth, minHeight), - width: minWidth, + list: list.New(nil, delegate, maxWidth, defaultHeight), + width: maxWidth, } m.list.SetFilteringEnabled(true) m.list.SetShowHelp(false) @@ -241,7 +255,7 @@ const maxListHeight = 28 func (m AssistModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - m.width = min(minWidth, msg.Width) + m.width = min(maxWidth, msg.Width) m.help.SetWidth(m.width) m.list.SetSize(m.width, min(max(5, msg.Height-6), maxListHeight)) @@ -255,7 +269,6 @@ func (m AssistModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.list.SetItems(msg.items) - m.list.Title = fmt.Sprintf("%s (%d)", currentCategory, len(msg.items)) m.err = nil return m, nil @@ -294,13 +307,16 @@ func (m AssistModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.help.ShowAll = !m.help.ShowAll return m, nil default: - if cl, ok := m.cfg.CategoryByKey(msg.String()); ok { + for _, cb := range m.keys.CategoryBindings { + if !key.Matches(msg, cb.binding) { + continue + } currentCategory := m.cfg.Categories[m.categoryIdx].Name - if cl.Name == currentCategory { - m.err = fmt.Errorf("already in %s", cl.Name) + if cb.name == currentCategory { + m.err = fmt.Errorf("already in %s", cb.name) return m, nil } - return m, m.reclassifyCmd(cl.Name) + return m, m.reclassifyCmd(cb.name) } } } diff --git a/internal/model/form.go b/internal/model/form.go index 3f0fd56..73f2f82 100644 --- a/internal/model/form.go +++ b/internal/model/form.go @@ -119,7 +119,7 @@ func NewNoteFormModel(cfg *config.Config, seed note.Draft) (NoteFormModel, error } } if idx < 0 { - return NoteFormModel{}, fmt.Errorf("unknown category %q", seed.Category) + return NoteFormModel{}, cfg.UnknownCategoryError(seed.Category) } s := newStyles() @@ -177,7 +177,7 @@ func NewNoteFormModel(cfg *config.Config, seed note.Draft) (NoteFormModel, error bodyCursorReset: seed.Body != "", styles: s, keys: noteKeys, - width: minWidth, + width: maxWidth, } m, _ = m.updateFocus() return m, nil @@ -346,13 +346,13 @@ func (m NoteFormModel) Init() tea.Cmd { func (m NoteFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - m.width = min(minWidth, msg.Width) + m.width = min(maxWidth, msg.Width) inputWidth := max(20, m.width-20) for i := range m.inputs { m.inputs[i].SetWidth(inputWidth) } m.bodyInput.SetWidth(inputWidth) - m.bodyInput.SetHeight(minHeight) + m.bodyInput.SetHeight(defaultHeight) case tea.BackgroundColorMsg: m.bodyInput.SetStyles(textarea.DefaultStyles(msg.IsDark())) diff --git a/internal/model/tui.go b/internal/model/tui.go index b8d346e..5346649 100644 --- a/internal/model/tui.go +++ b/internal/model/tui.go @@ -9,8 +9,8 @@ import ( ) const ( - minWidth = 120 - minHeight = 20 + maxWidth = 120 + defaultHeight = 20 ) type styles struct { diff --git a/internal/note/note.go b/internal/note/note.go index 6278f8a..33582e3 100644 --- a/internal/note/note.go +++ b/internal/note/note.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "time" @@ -27,11 +28,6 @@ type Note struct { Metadata } -// HasCompleteMetadata reports whether all frontmatter fields are present. -func (n Note) HasCompleteMetadata() bool { - return n.IsComplete() -} - // Draft is the creation-time representation of a note. Created may be empty // and is populated when the draft is converted to a Note. type Draft struct { @@ -240,11 +236,17 @@ type Result struct { // IngestFile reads the source file into the draft body when FromFile is set // and Body is empty, merging any file frontmatter metadata with the draft's -// existing metadata (draft values take precedence). +// existing metadata (draft values take precedence). FromFile is expanded once +// here, so parsing, form preview, and source removal all see the same path. func IngestFile(d Draft) (Draft, error) { if d.FromFile == "" || d.Body != "" { return d, nil } + expanded, err := fs.ExpandPath(d.FromFile) + if err != nil { + return Draft{}, fmt.Errorf("expand source path %q: %w", d.FromFile, err) + } + d.FromFile = expanded parsed, err := Parse(d.FromFile) if err != nil { return Draft{}, fmt.Errorf("parse source file %q: %w", d.FromFile, err) @@ -292,16 +294,7 @@ func Add(cfg *config.Config, d Draft) (Result, error) { if d.Synopsis == "" { d.Synopsis = parsed.Synopsis } - var missing []string - if d.Category == "" { - missing = append(missing, "category") - } - if d.Source == "" { - missing = append(missing, "source") - } - if d.Synopsis == "" { - missing = append(missing, "synopsis") - } + missing := slices.DeleteFunc(d.Metadata.MissingFields(), func(f string) bool { return f == "created" }) if len(missing) > 0 { return Result{}, fmt.Errorf("incomplete frontmatter: missing %s", strings.Join(missing, ", ")) } @@ -348,7 +341,7 @@ func Create(cfg *config.Config, d Draft) (string, error) { cl, ok := cfg.CategoryByName(d.Category) if !ok { - return "", fmt.Errorf("unknown category %q; valid: %s", d.Category, strings.Join(cfg.CategoryNames(), ", ")) + return "", cfg.UnknownCategoryError(d.Category) } path := filepath.Join(cl.Path, d.Slug()+".md") diff --git a/internal/note/note_test.go b/internal/note/note_test.go index 1de6f0f..cf1cb91 100644 --- a/internal/note/note_test.go +++ b/internal/note/note_test.go @@ -249,6 +249,35 @@ func TestIngestFileDirectorySource(t *testing.T) { } } +func TestIngestFileExpandsTilde(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("no home directory available") + } + src, err := os.CreateTemp(home, "park-tilde-test-*.md") + if err != nil { + t.Fatalf("CreateTemp() error = %v", err) + } + t.Cleanup(func() { _ = os.Remove(src.Name()) }) + if _, err := src.WriteString("# Tilde\n\nbody\n"); err != nil { + t.Fatalf("WriteString() error = %v", err) + } + if err := src.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + d, err := IngestFile(Draft{FromFile: "~/" + filepath.Base(src.Name())}) + if err != nil { + t.Fatalf("IngestFile() error = %v", err) + } + if d.FromFile != src.Name() { + t.Errorf("FromFile = %q, want expanded %q", d.FromFile, src.Name()) + } + if d.Body != "# Tilde\n\nbody" { + t.Errorf("body = %q, want file body", d.Body) + } +} + func TestDraftH1(t *testing.T) { tests := []struct { name string diff --git a/internal/store/list.go b/internal/store/list.go index a629b8d..21c6544 100644 --- a/internal/store/list.go +++ b/internal/store/list.go @@ -47,7 +47,7 @@ func selectedCategories(cfg *config.Config, opts ListOptions) ([]config.Category want := make(map[string]struct{}, len(opts.Categories)) for _, name := range opts.Categories { if _, ok := cfg.CategoryByName(name); !ok { - return nil, fmt.Errorf("unknown category %q; valid: %s", name, strings.Join(cfg.CategoryNames(), ", ")) + return nil, cfg.UnknownCategoryError(name) } want[name] = struct{}{} } diff --git a/internal/store/store.go b/internal/store/store.go index 0ecfefb..24ffcf6 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -69,7 +69,7 @@ func Check(cfg *config.Config) ([]string, error) { func Scan(cfg *config.Config, categoryName string) ([]Item, error) { cl, ok := cfg.CategoryByName(categoryName) if !ok { - return nil, fmt.Errorf("unknown category %q; valid: %s", categoryName, strings.Join(cfg.CategoryNames(), ", ")) + return nil, cfg.UnknownCategoryError(categoryName) } entries, err := os.ReadDir(cl.Path) @@ -107,42 +107,41 @@ func Scan(cfg *config.Config, categoryName string) ([]Item, error) { return items, nil } -// Reclassify moves a file (looked up by filename across all category folders) -// into the target category folder and rewrites its frontmatter category field -// to match. This is the "triage decision" primitive everything else builds on. +// Reclassify moves a file into the target category folder and rewrites its +// frontmatter category field to match. The file may be named by a bare +// basename (searched across all category folders) or by a literal path, using +// the same resolution rules as ResolvePath. This is the "triage decision" +// primitive everything else builds on. func Reclassify(cfg *config.Config, filename string, targetCategory string) error { cl, ok := cfg.CategoryByName(targetCategory) if !ok { - return fmt.Errorf("unknown category %q; valid: %s", targetCategory, strings.Join(cfg.CategoryNames(), ", ")) - } - - var src string - var n note.Note - for _, c := range cfg.Categories { - candidate := filepath.Join(c.Path, filename) - if _, statErr := os.Stat(candidate); statErr == nil { - src = candidate - var parseErr error - n, parseErr = note.Parse(candidate) - if parseErr != nil { - return fmt.Errorf("parse frontmatter for %q: %w", candidate, parseErr) - } - break - } + return cfg.UnknownCategoryError(targetCategory) + } + + src, err := ResolvePath(cfg, filename) + if err != nil { + return err } - if src == "" { - return os.ErrNotExist + src, err = filepath.Abs(src) + if err != nil { + return fmt.Errorf("resolve path %q: %w", filename, err) } if filepath.Dir(src) == cl.Path { return fmt.Errorf("already in %s", targetCategory) } + n, err := note.Parse(src) + if err != nil { + return fmt.Errorf("parse frontmatter for %q: %w", src, err) + } + n.Category = targetCategory - dst := filepath.Join(cl.Path, filename) + base := filepath.Base(src) + dst := filepath.Join(cl.Path, base) if _, err := os.Stat(dst); err == nil { - return fmt.Errorf("already exists in %s: %s", targetCategory, filename) + return fmt.Errorf("already exists in %s: %s", targetCategory, base) } else if !os.IsNotExist(err) { return fmt.Errorf("check destination %q: %w", dst, err) } @@ -171,13 +170,18 @@ func FormatInitResult(created, existed []string) string { return msg } -// ResolvePath accepts either a bare filename (searched across all category -// folders) or a full path used as-is. It returns os.ErrNotExist when no file -// can be resolved. +// ResolvePath accepts either a bare basename (searched across all category +// folders) or a path used as-is. A basename that contains a path separator is +// treated as a literal path and is never joined onto a category folder, so a +// path can never double-join. It returns os.ErrNotExist when no file can be +// resolved. func ResolvePath(cfg *config.Config, filename string) (string, error) { if _, err := os.Stat(filename); err == nil { return filename, nil } + if strings.ContainsAny(filename, `/\`) { + return "", os.ErrNotExist + } for _, cl := range cfg.Categories { p := filepath.Join(cl.Path, filename) if _, err := os.Stat(p); err == nil { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 3ecac38..f64e95a 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "github.com/polymorcodeus/park/internal/config" @@ -229,6 +230,85 @@ func TestReclassifyDestinationExists(t *testing.T) { } } +func TestReclassifyByLiteralPath(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + if _, _, err := Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + + path, err := note.Create(cfg, note.Draft{Filename: "Literal Path", Metadata: note.Metadata{Synopsis: "synopsis", Source: "test", Category: "inbox"}}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + filename := filepath.Base(path) + + if err := Reclassify(cfg, path, "projects"); err != nil { + t.Fatalf("Reclassify(%q) error = %v", path, err) + } + + projectsPath := filepath.Join(tmp, "_projects", filename) + if _, err := os.Stat(projectsPath); err != nil { + t.Errorf("file missing in projects: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("file still exists in inbox: %v", err) + } +} + +func TestReclassifyByRelativePath(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + if _, _, err := Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + + path, err := note.Create(cfg, note.Draft{Filename: "Relative Path", Metadata: note.Metadata{Synopsis: "synopsis", Source: "test", Category: "inbox"}}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + filename := filepath.Base(path) + + t.Chdir(tmp) + rel := filepath.Join("_inbox", filename) + + if err := Reclassify(cfg, rel, "projects"); err != nil { + t.Fatalf("Reclassify(%q) error = %v", rel, err) + } + + if _, err := os.Stat(filepath.Join(tmp, "_projects", filename)); err != nil { + t.Errorf("file missing in projects: %v", err) + } +} + +func TestReclassifyRelativePathSameCategory(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + if _, _, err := Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + + path, err := note.Create(cfg, note.Draft{Filename: "Same Relative", Metadata: note.Metadata{Synopsis: "synopsis", Source: "test", Category: "inbox"}}) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + filename := filepath.Base(path) + + t.Chdir(tmp) + rel := filepath.Join("_inbox", filename) + + err = Reclassify(cfg, rel, "inbox") + if err == nil { + t.Fatal("expected error when reclassifying a relative path to the same category") + } + if !strings.Contains(err.Error(), "already in") { + t.Errorf("error = %q, want already-in message", err.Error()) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("original file was moved or removed: %v", err) + } +} + func TestScan(t *testing.T) { tmp := t.TempDir() cfg := config.DefaultConfig(tmp) @@ -296,3 +376,26 @@ func TestResolvePath(t *testing.T) { t.Errorf("ResolvePath(missing) error = %v, want os.ErrNotExist", err) } } + +func TestResolvePathDoesNotDoubleJoin(t *testing.T) { + tmp := t.TempDir() + cfg := config.DefaultConfig(tmp) + if _, _, err := Init(cfg); err != nil { + t.Fatalf("Init() error = %v", err) + } + + // A naive category join of "_inbox/nested.md" would land on this nested + // file; a path-like argument must be treated as a literal path instead. + nested := filepath.Join(cfg.Categories[0].Path, "_inbox") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(filepath.Join(nested, "nested.md"), []byte("x"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + _, err := ResolvePath(cfg, filepath.Join("_inbox", "nested.md")) + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("ResolvePath(path) error = %v, want os.ErrNotExist (path must not double-join)", err) + } +} From f4d4f4805458758b2822f06235ce535672c7a759 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sun, 13 Sep 2026 15:41:12 -0500 Subject: [PATCH 2/4] docs: README updates --- README.md | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 4825432..04fd600 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ # park -[![Go Version](https://img.shields.io/github/go-mod/go-version/polymorcodeus/park)](https://go.dev/) [![Build Status](https://img.shields.io/github/actions/workflow/status/polymorcodeus/park/ci.yml?branch=main)](https://github.com/polymorcodeus/park/actions) +[![Go Version](https://img.shields.io/github/go-mod/go-version/polymorcodeus/park)](https://go.dev/) [![Build Status](https://img.shields.io/github/actions/workflow/status/polymorcodeus/park/ci.yml?branch=main)](https://github.com/polymorcodeus/park/actions) [![License](https://img.shields.io/github/license/polymorcodeus/park)](./LICENSE) [![Go Reference](https://pkg.go.dev/badge/github.com/polymorcodeus/park/schema.svg)](https://pkg.go.dev/github.com/polymorcodeus/park/schema) **A parking lot for markdown notes, organized as IPAA (Inbox / Projects / Areas / Archive).** @@ -20,7 +20,7 @@ Park surfaces notes mid-coding-session and keeps the Inbox skim-able through fro park init park new "revisit dashboard caching approach" \ -s "current TTL feels wrong, worth a spike" -src my-cli-tool -park new "keep an eye on API rate limits" -c area +park new "keep an eye on API rate limits" -c areas park list park assist park reclassify 1767786622-idea.md -c projects @@ -34,6 +34,8 @@ Quick install (downloads the latest release to `/usr/local/bin`): curl -sSL https://raw.githubusercontent.com/polymorcodeus/park/main/install.sh | bash ``` +The `go install` and source builds require Go 1.26.4+. + Or install via Go: ```bash @@ -48,8 +50,6 @@ cd park make build ``` -Requires Go 1.26.4+. - ## Setup ```bash @@ -81,9 +81,11 @@ Notes are plain markdown files with frontmatter, one folder per category: └── _archive/ # inactive, retained for reference ``` +Layout shown for the Linux default root; on macOS the root is `~/Library/Application Support/park`. + ### Frontmatter -```yaml +```text --- category: inbox created: 2026-07-16 @@ -92,14 +94,14 @@ synopsis: current TTL feels wrong, worth a spike --- ``` -Frontmatter is parsed line-by-line — no YAML dependency. Four fields: +Frontmatter is parsed line-by-line -- no YAML dependency. Four fields: | field | purpose | |-------|---------| | `category` | redundant with folder, kept so ad-hoc files still self-describe | | `created` | ISO 8601 date, set at park time | | `source` | where the note came from (repo, chat, stray idea) | -| `synopsis` | one-line summary — the entire mechanism that makes triage fast | +| `synopsis` | one-line summary -- the entire mechanism that makes triage fast | `synopsis` is the key design decision: read it, decide whether to open the file, move on. `source` lets future-you reconstruct *why* a note exists without re-reading it. @@ -131,7 +133,7 @@ The JSON output includes the canonical category enum, field kinds, the date form | `assist` | open the tabbed TUI browser | | `show [--plain]` | render a note to the terminal (plain when piped, or with `--plain`) | | `reclassify -c ` | reclassify a note (alias `recat`) | -| `config` | print the default TOML config | +| `config` | print the loaded TOML config | | `schema` | print the frontmatter schema contract | | `schema --json` | print the contract as machine-readable JSON | @@ -215,7 +217,7 @@ park new -f ~/Downloads/meeting-notes.md \ -s "Q3 planning recap" -src "Slack export" ``` -The file is read, wrapped with frontmatter, and moved into the park. The original is left untouched. +The file is read, wrapped with frontmatter, and moved into the park; the original file is removed. **From stdin** (pipe): @@ -235,8 +237,7 @@ When stdin is not a terminal, `park new` reads the entire input as the note body ## TUI -`park assist` opens a CLI tabbed browser across all configured categories and allows for interactively moving documents -between categories. +`park assist` opens a tabbed TUI browser across all configured categories, with interactive moves between categories. | key | action | |-----|--------| @@ -279,7 +280,7 @@ park new -f /tmp/claude-output.md \ -s "database schema redesign proposal" -src "Claude" ``` -The file is read, frontmatter is injected, and it is moved into the category folder. The original file stays in place. +The file is moved into the category folder and the original is removed, same as any `--from-file` ingest; see [Ingestion](#ingestion). ### Verify setup from an agent @@ -310,7 +311,7 @@ park show inbox-note.md | grep -i "deadline" ## Configuration -Config lives at `/config` as TOML. Run `park config` to print the default: +Config lives at `/config` as TOML. Run `park config` to print the loaded config (the built-in default when no config file exists): ```toml default_category = "inbox" @@ -359,3 +360,9 @@ git clone https://github.com/polymorcodeus/park.git cd park make check # fmt, vet, lint, test ``` + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full setup. + +## License + +[MIT](./LICENSE) From 0e60521e7e9555a43fb79a4bdaa878d8106c5420 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sun, 13 Sep 2026 15:41:53 -0500 Subject: [PATCH 3/4] docs: convention docs in CONTRIBUTING --- CONTRIBUTING.md | 134 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3597c83 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,134 @@ +# Contributing + +## Prerequisites + +- Go 1.26.4+ +- `golangci-lint` for `make lint` (install with `make deps`) + +## Getting started + +```bash +git clone https://github.com/polymorcodeus/park.git +cd park +make build +``` + +## Before opening a PR + +```bash +make check # fmt, vet, lint, test +``` + +- Keep PRs focused: one behavior change per PR. +- Update `README.md` and `CONTRIBUTING.md` when behavior, commands, or flags change; docs are part of done. +- Follow the conventions in this file (package boundaries, error wrapping, message ownership). + +## Package boundaries + +| Package | Does | Imports | +|---------|------|---------| +| `schema` | public frontmatter contract; canonical categories, version, and write template | stdlib only | +| `internal/config` | configuration schema, loading, validation | `internal/fs`, `schema` | +| `internal/note` | note content, frontmatter parsing/writing, creation, ingestion (`Note`, `Parse`, `Write`, `Add`, `Create`) | `internal/config`, `internal/fs`, `schema` | +| `internal/store` | on-disk item management, scanning, reclassification, list formatting | `internal/config`, `internal/note`, `schema` | +| `internal/render` | glamour-based rendering | `internal/note` | +| `internal/theme` | color constants | stdlib only | +| `internal/fs` | filesystem helpers (ExpandPath) | stdlib only | +| `internal/model` | Bubble Tea TUI screens | `internal/config`, `internal/note`, `internal/store`, `internal/theme` | +| `cmd/park` | CLI tree + wiring | everything | + +## Conventions + +These rules keep the package boundaries above meaningful as the codebase grows. + +### File I/O and frontmatter + +- `internal/fs` is the only package that expands `~` and `$HOME`. Ingestion + paths (`Draft.FromFile`) are normalized exactly once, in `note.IngestFile`, + so parsing, form preview, and source-file removal all see the same path. +- `internal/note` owns all frontmatter parsing and writing. Code outside this + package should not parse `---` blocks by hand. +- `internal/store` owns category-folder operations, resolving filenames to + full paths, and `park list` grouping/formatting (plain and JSON). Filename + resolution is unified in `ResolvePath`: a bare basename is searched across + every configured category folder, while a value containing a path separator + is treated as a literal path and never joined onto a category folder. + `park show` and `park reclassify` both resolve their `` argument this + way, so a path can never double-join. +- `internal/model` may call `note.Create`, `store.Scan`, and `store.Reclassify`, + but should not read files directly from disk except through those packages. +- `cmd/park` parses CLI flags and delegates all file/content work to + `internal/note` or `internal/store`. + +### Data models + +- `schema.Frontmatter` is the canonical metadata block: `Category`, + `Created`, `Source`, `Synopsis`. It lives in the public `schema` package so + downstream tooling can import the contract instead of re-deriving it. +- `note.Metadata` is an alias to `schema.Frontmatter`. It is not validated in + isolation because its completeness depends on context. +- `note.Draft` is the creation-time model: `Filename`, `Body`, `FromFile`, + plus `Metadata`. `Created` may be empty; it is populated when the draft is + converted to a note. `Draft.ReadyToCreate()` checks `Filename`, + `Metadata.Category`, `Metadata.Source`, and `Metadata.Synopsis`. +- `note.Note` is the persisted model: `Path`, `Body`, plus complete + `Metadata` (`Created` always set). Completeness checks go through + `schema.Frontmatter.IsComplete()`; there is no `Note`-level completeness + wrapper. +- `store.Item` is the read/scanned model. It embeds `note.Metadata` plus + `Path`, `Filename`, and `ModTime`. +- `store.Group` is a category name plus its `[]Item`; `store.List` returns + groups in config order and `store.FormatList`/`store.WriteListJSON` render + them for `park list`. +- `Body` has the same meaning in `Draft` and `Note` (markdown content below + the frontmatter). During `Draft` → `Note` conversion, any embedded frontmatter + in `Body` is stripped and merged into `Metadata`, so `Note.Body` is always + clean. + +### TUI + +- Key bindings are matched in `Update` from the same `key.Binding` values that + render help (see `categoryBinding` in `internal/model/assist.go`, which pairs + each category name with its binding), so help text and behavior cannot drift. + +### Errors and output + +- Errors are wrapped with `fmt.Errorf("...: %w", err)` when crossing package + boundaries. +- User-facing message formatting belongs in the package that owns the data. + `cmd/park` wires output to the terminal but does not format store results. +- Category-validation errors are constructed once, by + `Config.UnknownCategoryError`; CLI `Before` hooks and domain packages call it + instead of hand-writing the "unknown category" message. +- Avoid `init()` for logic that can be explicit in `main()` or a constructor. +- Exported helpers with no callers get deleted, not kept "just in case": the + `unused` linter cannot see exported identifiers, so dead API only surfaces + in review. + +## Styling + +The TUI uses the Charm design system tokens: + +- Page background: `#14121a` +- Raised surface: `#1c1a24` +- Primary text: `#f5f1fa` +- Muted text: `#a79fc0` +- Faint text: `#6f6785` +- Accent purple: `#7d56f4` +- Accent pink: `#FF4081` + +Colors are defined as exported constants in `internal/theme/theme.go` so both +the TUI (`internal/model`) and the CLI's styled error output (`cmd/park`) +share the same palette. + +## Implementation notes + +- Frontmatter is flat `key: value` parsed line-by-line (no YAML dependency). +- `Reclassify` rewrites frontmatter *before* moving the file, so a failed move never + leaves a file in an inconsistent state. +- `schema` is a public, stdlib-only package. Other tools can import + `github.com/polymorcodeus/park/schema` to pin the frontmatter contract. +- `internal/config` has no dependency on `main.go`; importing the focused + packages into another CLI is just wiring commands to the exported functions. +- `Config` is loaded once in the CLI `Before` hook and passed as `*config.Config` + to all command helpers. From 094cd18518a15b61c1cfb3d43526f7a3730c2b66 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Sun, 13 Sep 2026 15:42:23 -0500 Subject: [PATCH 4/4] chore: version bump --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 992ac75..b0c2058 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.5.1 +v0.5.2