Skip to content

Commit 94e9add

Browse files
committed
feat: new list subcommand with category filters and schema pinned json output
1 parent 29959a8 commit 94e9add

8 files changed

Lines changed: 732 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ park init
2121
park new "revisit dashboard caching approach" \
2222
-s "current TTL feels wrong, worth a spike" -src my-cli-tool
2323
park new "keep an eye on API rate limits" -c area
24+
park list
2425
park assist
2526
park reclassify 1767786622-idea.md -c projects
2627
```
@@ -124,6 +125,7 @@ The JSON output includes the canonical category enum, field kinds, the date form
124125
| `init` | create the category folders (idempotent) |
125126
| `check` | verify category folders exist (useful for automation) |
126127
| `new [title]` | park a new note (alias `add`) |
128+
| `list [--all] [-c <cat>] [--json]` | list parked notes grouped by category (alias `ls`) |
127129
| `assist` | open the tabbed TUI browser |
128130
| `show <file>` | glamour-render a note, no TUI |
129131
| `reclassify <file> -c <cat>` | reclassify a note (alias `recat`) |
@@ -141,6 +143,57 @@ The JSON output includes the canonical category enum, field kinds, the date form
141143
| `-f, --from-file` | ingest an existing markdown file |
142144
| `--filename` | explicit file slug |
143145

146+
### `park list` options
147+
148+
| option | purpose |
149+
|--------|---------|
150+
| `--all` | include categories excluded by config (for example archive) |
151+
| `-c, --category` | only list these categories (repeatable; overrides exclusion) |
152+
| `--json` | emit a versioned, machine-readable envelope |
153+
154+
`park list` renders one block per category: an upper-cased banner followed by each note's filename and frontmatter fields. Notes within a category are ordered oldest-modified first, and categories marked `excluded` in the config are hidden until `--all` is passed or the category is named explicitly with `-c`.
155+
156+
```
157+
INBOX
158+
-----
159+
160+
filename: ttl-spike.md
161+
category: inbox
162+
created: 2026-07-16
163+
source: terminal
164+
synopsis: current TTL feels wrong, worth a spike
165+
166+
PROJECTS
167+
--------
168+
169+
filename: caching.md
170+
category: projects
171+
created: 2026-07-18
172+
source: my-cli-tool
173+
synopsis: dashboard caching rework
174+
```
175+
176+
`park list --json` flattens every group into a single `items` array, each item carrying its `category`, `path`, and RFC 3339 `modified` timestamp, wrapped in a versioned envelope:
177+
178+
```json
179+
{
180+
"schema_version": 1,
181+
"items": [
182+
{
183+
"filename": "ttl-spike.md",
184+
"path": "/home/you/.config/park/_inbox/ttl-spike.md",
185+
"category": "inbox",
186+
"created": "2026-07-16",
187+
"source": "terminal",
188+
"synopsis": "current TTL feels wrong, worth a spike",
189+
"modified": "2026-07-16T19:03:11Z"
190+
}
191+
]
192+
}
193+
```
194+
195+
`schema_version` tracks the frontmatter contract exposed by `park schema`, so consumers can detect drift.
196+
144197
### Ingestion
145198

146199
`park new` accepts content through two non-interactive paths:
@@ -244,29 +297,32 @@ Config lives at `<park-root>/config` as TOML. Run `park config` to print the def
244297
```toml
245298
default_category = "inbox"
246299

247-
[[categories]]
300+
[[category]]
248301
name = "inbox"
249302
path = "~/.config/park/_inbox"
250303
key = "i"
251304

252-
[[categories]]
305+
[[category]]
253306
name = "projects"
254307
path = "~/.config/park/_projects"
255308
key = "p"
256309

257-
[[categories]]
310+
[[category]]
258311
name = "areas"
259312
path = "~/.config/park/_areas"
260313
key = "a"
261314

262-
[[categories]]
315+
[[category]]
263316
name = "archive"
264317
path = "~/.config/park/_archive"
265318
key = "x"
319+
excluded = true
266320
```
267321

268322
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.
269323

324+
Set `excluded = true` on a category to hide it from `park list` by default; it reappears with `park list --all` or when named explicitly (`park list -c archive`). The built-in config ships with `archive` excluded.
325+
270326
## Tech stack
271327

272328
| component | library |

‎cmd/park/main.go‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ func newCommand() *cli.Command {
5555
reclassifyCategory string
5656
newCategory string
5757
schemaJSON bool
58+
listJSON bool
59+
listAll bool
5860
)
5961

6062
defaultRoot := os.Getenv("PARK_ROOT")
@@ -153,6 +155,46 @@ func newCommand() *cli.Command {
153155
return nil
154156
},
155157
},
158+
{
159+
Name: "list",
160+
Aliases: []string{"ls"},
161+
Usage: "list parked notes grouped by category",
162+
Flags: []cli.Flag{
163+
&cli.BoolFlag{
164+
Name: "json",
165+
Destination: &listJSON,
166+
Usage: "output machine-readable JSON",
167+
},
168+
&cli.BoolFlag{
169+
Name: "all",
170+
Destination: &listAll,
171+
Usage: "include categories excluded by config",
172+
},
173+
&cli.StringSliceFlag{
174+
Name: "category",
175+
Aliases: []string{"c"},
176+
Usage: "only list these categories (repeatable; overrides exclusion)",
177+
},
178+
},
179+
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
180+
for _, name := range cmd.StringSlice("category") {
181+
if !cfg.HasCategory(name) {
182+
return ctx, styledExit(fmt.Errorf("unknown category %q; valid: %s", name, strings.Join(cfg.CategoryNames(), ", ")), 2)
183+
}
184+
}
185+
return ctx, nil
186+
},
187+
Action: func(ctx context.Context, cmd *cli.Command) error {
188+
opts := store.ListOptions{
189+
IncludeExcluded: listAll,
190+
Categories: cmd.StringSlice("category"),
191+
}
192+
if err := listPark(cfg, opts, listJSON, cmd.Root().Writer); err != nil {
193+
return styledExit(err, 1)
194+
}
195+
return nil
196+
},
197+
},
156198
{
157199
Name: "new",
158200
Aliases: []string{"add"},

‎cmd/park/main_test.go‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ package cmd
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"os"
78
"path/filepath"
89
"strings"
910
"testing"
1011

12+
"github.com/polymorcodeus/park/schema"
1113
"github.com/urfave/cli/v3"
1214
)
1315

@@ -152,3 +154,165 @@ func TestStyledExit(t *testing.T) {
152154
t.Errorf("error = %q, want boom message", err.Error())
153155
}
154156
}
157+
158+
// writeNote writes a minimal valid note into the given category folder.
159+
func writeNote(t *testing.T, dir, name, category, synopsis string) {
160+
t.Helper()
161+
content := "---\ncategory: " + category + "\ncreated: 2026-01-01\nsource: test\nsynopsis: " + synopsis + "\n---\n\nbody\n"
162+
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
163+
t.Fatalf("write note %q: %v", name, err)
164+
}
165+
}
166+
167+
func TestListCommandDefaultExcludesArchive(t *testing.T) {
168+
root := t.TempDir()
169+
if _, _, err := runPark(t, root, "init"); err != nil {
170+
t.Fatalf("init error = %v", err)
171+
}
172+
writeNote(t, filepath.Join(root, "_inbox"), "inbox-note.md", "inbox", "an inbox item")
173+
writeNote(t, filepath.Join(root, "_archive"), "archive-note.md", "archive", "an archived item")
174+
175+
out, _, err := runPark(t, root, "list")
176+
if err != nil {
177+
t.Fatalf("list error = %v", err)
178+
}
179+
if !strings.Contains(out, "inbox-note.md") {
180+
t.Errorf("list output missing inbox note:\n%s", out)
181+
}
182+
if strings.Contains(out, "archive-note.md") {
183+
t.Errorf("list output included excluded archive:\n%s", out)
184+
}
185+
}
186+
187+
func TestListAllIncludesArchive(t *testing.T) {
188+
root := t.TempDir()
189+
if _, _, err := runPark(t, root, "init"); err != nil {
190+
t.Fatalf("init error = %v", err)
191+
}
192+
writeNote(t, filepath.Join(root, "_archive"), "archive-note.md", "archive", "an archived item")
193+
194+
out, _, err := runPark(t, root, "list", "--all")
195+
if err != nil {
196+
t.Fatalf("list --all error = %v", err)
197+
}
198+
if !strings.Contains(out, "archive-note.md") {
199+
t.Errorf("list --all output missing archive note:\n%s", out)
200+
}
201+
}
202+
203+
func TestListCategoryOverridesExclusion(t *testing.T) {
204+
root := t.TempDir()
205+
if _, _, err := runPark(t, root, "init"); err != nil {
206+
t.Fatalf("init error = %v", err)
207+
}
208+
writeNote(t, filepath.Join(root, "_archive"), "archive-note.md", "archive", "an archived item")
209+
210+
out, _, err := runPark(t, root, "list", "--category", "archive")
211+
if err != nil {
212+
t.Fatalf("list --category error = %v", err)
213+
}
214+
if !strings.Contains(out, "archive-note.md") {
215+
t.Errorf("list --category archive output missing archive note:\n%s", out)
216+
}
217+
}
218+
219+
func TestListCategoryUnknown(t *testing.T) {
220+
_, _, err := runPark(t, t.TempDir(), "list", "--category", "bogus")
221+
if err == nil {
222+
t.Fatal("expected error for unknown category")
223+
}
224+
if got := exitCode(t, err); got != 2 {
225+
t.Errorf("exit code = %d, want 2", got)
226+
}
227+
}
228+
229+
func TestListJSON(t *testing.T) {
230+
root := t.TempDir()
231+
if _, _, err := runPark(t, root, "init"); err != nil {
232+
t.Fatalf("init error = %v", err)
233+
}
234+
writeNote(t, filepath.Join(root, "_inbox"), "json-note.md", "inbox", "a json item")
235+
236+
out, _, err := runPark(t, root, "list", "--json")
237+
if err != nil {
238+
t.Fatalf("list --json error = %v", err)
239+
}
240+
241+
var env struct {
242+
SchemaVersion int `json:"schema_version"`
243+
Items []struct {
244+
Filename string `json:"filename"`
245+
Category string `json:"category"`
246+
Synopsis string `json:"synopsis"`
247+
Modified string `json:"modified"`
248+
} `json:"items"`
249+
}
250+
if err := json.Unmarshal([]byte(out), &env); err != nil {
251+
t.Fatalf("parse list json = %v\n%s", err, out)
252+
}
253+
if env.SchemaVersion != schema.SchemaVersion {
254+
t.Errorf("schema_version = %d, want %d", env.SchemaVersion, schema.SchemaVersion)
255+
}
256+
if len(env.Items) != 1 {
257+
t.Fatalf("items = %d, want 1", len(env.Items))
258+
}
259+
if env.Items[0].Filename != "json-note.md" || env.Items[0].Category != "inbox" {
260+
t.Errorf("item = %+v, want json-note.md in inbox", env.Items[0])
261+
}
262+
if env.Items[0].Synopsis != "a json item" {
263+
t.Errorf("synopsis = %q, want %q", env.Items[0].Synopsis, "a json item")
264+
}
265+
if env.Items[0].Modified == "" {
266+
t.Error("modified is empty, want a timestamp")
267+
}
268+
}
269+
270+
func TestListJSONCategoryFilter(t *testing.T) {
271+
root := t.TempDir()
272+
if _, _, err := runPark(t, root, "init"); err != nil {
273+
t.Fatalf("init error = %v", err)
274+
}
275+
writeNote(t, filepath.Join(root, "_inbox"), "inbox-note.md", "inbox", "an inbox item")
276+
writeNote(t, filepath.Join(root, "_archive"), "archive-note.md", "archive", "an archived item")
277+
278+
out, _, err := runPark(t, root, "list", "--json", "--category", "archive")
279+
if err != nil {
280+
t.Fatalf("list --json --category error = %v", err)
281+
}
282+
283+
var env struct {
284+
SchemaVersion int `json:"schema_version"`
285+
Items []struct {
286+
Filename string `json:"filename"`
287+
Category string `json:"category"`
288+
} `json:"items"`
289+
}
290+
if err := json.Unmarshal([]byte(out), &env); err != nil {
291+
t.Fatalf("parse list json = %v\n%s", err, out)
292+
}
293+
if env.SchemaVersion != schema.SchemaVersion {
294+
t.Errorf("schema_version = %d, want %d", env.SchemaVersion, schema.SchemaVersion)
295+
}
296+
if len(env.Items) != 1 {
297+
t.Fatalf("items = %d, want 1", len(env.Items))
298+
}
299+
if env.Items[0].Filename != "archive-note.md" || env.Items[0].Category != "archive" {
300+
t.Errorf("item = %+v, want archive-note.md in archive", env.Items[0])
301+
}
302+
}
303+
304+
func TestListAlias(t *testing.T) {
305+
root := t.TempDir()
306+
if _, _, err := runPark(t, root, "init"); err != nil {
307+
t.Fatalf("init error = %v", err)
308+
}
309+
writeNote(t, filepath.Join(root, "_inbox"), "alias-note.md", "inbox", "an item")
310+
311+
out, _, err := runPark(t, root, "ls")
312+
if err != nil {
313+
t.Fatalf("ls error = %v", err)
314+
}
315+
if !strings.Contains(out, "alias-note.md") {
316+
t.Errorf("ls output missing note:\n%s", out)
317+
}
318+
}

‎cmd/park/park.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/polymorcodeus/park/internal/model"
1717
"github.com/polymorcodeus/park/internal/note"
1818
"github.com/polymorcodeus/park/internal/render"
19+
"github.com/polymorcodeus/park/internal/store"
1920
)
2021

2122
// isTerminal reports whether the given file descriptor is connected to an
@@ -151,6 +152,19 @@ func schemaPark(asJSON bool, w io.Writer) error {
151152
return nil
152153
}
153154

155+
// listPark lists parked notes grouped by category, either as plain text or as
156+
// the versioned JSON envelope.
157+
func listPark(cfg *config.Config, opts store.ListOptions, asJSON bool, w io.Writer) error {
158+
groups, err := store.List(cfg, opts)
159+
if err != nil {
160+
return err
161+
}
162+
if asJSON {
163+
return store.WriteListJSON(w, groups)
164+
}
165+
return store.FormatList(w, groups)
166+
}
167+
154168
func assistPark(cfg *config.Config, w io.Writer) error {
155169
m, err := model.NewAssistModel(cfg)
156170
if err != nil {

0 commit comments

Comments
 (0)